diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..781a7b55000896775f8caa6374086ba02165326f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.github +.git +*.md +.vscode +.gitignore +Makefile +docs +.eslintcache +.gocache \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..f43f7b211210969de06ba6fc40cd843466c39a1c --- /dev/null +++ b/.env.example @@ -0,0 +1,76 @@ +# 端口号 +# PORT=3000 +# 前端基础URL +# FRONTEND_BASE_URL=https://your-frontend-url.com + + +# 调试相关配置 +# 启用pprof +# ENABLE_PPROF=true +# 启用调试模式 +# DEBUG=true + +# 数据库相关配置 +# 数据库连接字符串 +# SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true +# 日志数据库连接字符串 +# LOG_SQL_DSN=user:password@tcp(127.0.0.1:3306)/logdb?parseTime=true +# SQLite数据库路径 +# SQLITE_PATH=/path/to/sqlite.db +# 数据库最大空闲连接数 +# SQL_MAX_IDLE_CONNS=100 +# 数据库最大打开连接数 +# SQL_MAX_OPEN_CONNS=1000 +# 数据库连接最大生命周期(秒) +# SQL_MAX_LIFETIME=60 + + +# 缓存相关配置 +# Redis连接字符串 +# REDIS_CONN_STRING=redis://user:password@localhost:6379/0 +# 同步频率(单位:秒) +# SYNC_FREQUENCY=60 +# 内存缓存启用 +# MEMORY_CACHE_ENABLED=true +# 渠道更新频率(单位:秒) +# CHANNEL_UPDATE_FREQUENCY=30 +# 批量更新启用 +# BATCH_UPDATE_ENABLED=true +# 批量更新间隔(单位:秒) +# BATCH_UPDATE_INTERVAL=5 + +# 任务和功能配置 +# 更新任务启用 +# UPDATE_TASK=true + +# 对话超时设置 +# 所有请求超时时间,单位秒,默认为0,表示不限制 +# RELAY_TIMEOUT=0 +# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值 +# STREAMING_TIMEOUT=300 + +# Gemini 识别图片 最大图片数量 +# GEMINI_VISION_MAX_IMAGE_NUM=16 + +# 会话密钥 +# SESSION_SECRET=random_string + +# 其他配置 +# 生成默认token +# GENERATE_DEFAULT_TOKEN=false +# Cohere 安全设置 +# COHERE_SAFETY_SETTING=NONE +# 是否统计图片token +# GET_MEDIA_TOKEN=true +# 是否在非流(stream=false)情况下统计图片token +# GET_MEDIA_TOKEN_NOT_STREAM=false +# 设置 Dify 渠道是否输出工作流和节点信息到客户端 +# DIFY_DEBUG=true + +# LinuxDo相关配置 +LINUX_DO_TOKEN_ENDPOINT=https://connect.linux.do/oauth2/token +LINUX_DO_USER_ENDPOINT=https://connect.linux.do/api/user + +# 节点类型 +# 如果是主节点则为master +# NODE_TYPE=master diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..7d85c840c2348285a2664f0422e9dfee04932ac8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.lockb filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.ico filter=lfs diff=lfs merge=lfs -text diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000000000000000000000000000000000..87747788d136c6817896657dca826fef8333d180 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: ['https://afdian.com/a/new-api'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000000000000000000000000000000000..dd688493883313b3d463110cdbc263ef7ecb6135 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,26 @@ +--- +name: 报告问题 +about: 使用简练详细的语言描述你遇到的问题 +title: '' +labels: bug +assignees: '' + +--- + +**例行检查** + +[//]: # (方框内删除已有的空格,填 x 号) ++ [ ] 我已确认目前没有类似 issue ++ [ ] 我已确认我已升级到最新版本 ++ [ ] 我已完整查看过项目 README,尤其是常见问题部分 ++ [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈 ++ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭** + +**问题描述** + +**复现步骤** + +**预期结果** + +**相关截图** +如果没有的话,请删除此节。 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report_en.md b/.github/ISSUE_TEMPLATE/bug_report_en.md new file mode 100644 index 0000000000000000000000000000000000000000..5c2506180e0422f2937f67a35385b09ae2d3f99b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report_en.md @@ -0,0 +1,26 @@ +--- +name: Bug Report +about: Describe the issue you encountered with clear and detailed language +title: '' +labels: bug +assignees: '' + +--- + +**Routine Checks** + +[//]: # (Remove the space in the box and fill with an x) ++ [ ] I have confirmed there are no similar issues currently ++ [ ] I have confirmed I have upgraded to the latest version ++ [ ] I have thoroughly read the project README, especially the FAQ section ++ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback ++ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly** + +**Issue Description** + +**Steps to Reproduce** + +**Expected Result** + +**Related Screenshots** +If none, please delete this section. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000000000000000000000000000000..5b8ee14fa533f3ad6225fd096cb62f85e5f837a3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: 项目群聊 + url: https://private-user-images.githubusercontent.com/61247483/283011625-de536a8a-0161-47a7-a0a2-66ef6de81266.jpeg?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTEiLCJleHAiOjE3MDIyMjQzOTAsIm5iZiI6MTcwMjIyNDA5MCwicGF0aCI6Ii82MTI0NzQ4My8yODMwMTE2MjUtZGU1MzZhOGEtMDE2MS00N2E3LWEwYTItNjZlZjZkZTgxMjY2LmpwZWc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBSVdOSllBWDRDU1ZFSDUzQSUyRjIwMjMxMjEwJTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDIzMTIxMFQxNjAxMzBaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT02MGIxYmM3ZDQyYzBkOTA2ZTYyYmVmMzQ1NjY4NjM1YjY0NTUzNTM5NjE1NDZkYTIzODdhYTk4ZjZjODJmYzY2JlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCZhY3Rvcl9pZD0wJmtleV9pZD0wJnJlcG9faWQ9MCJ9.TJ8CTfOSwR0-CHS1KLfomqgL0e4YH1luy8lSLrkv5Zg + about: QQ 群:629454374 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000000000000000000000000000000000..049d89c8de5bb667a49bf919910fe3f8445bece6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: 功能请求 +about: 使用简练详细的语言描述希望加入的新功能 +title: '' +labels: enhancement +assignees: '' + +--- + +**例行检查** + +[//]: # (方框内删除已有的空格,填 x 号) ++ [ ] 我已确认目前没有类似 issue ++ [ ] 我已确认我已升级到最新版本 ++ [ ] 我已完整查看过项目 README,已确定现有版本无法满足需求 ++ [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈 ++ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭** + +**功能描述** + +**应用场景** diff --git a/.github/ISSUE_TEMPLATE/feature_request_en.md b/.github/ISSUE_TEMPLATE/feature_request_en.md new file mode 100644 index 0000000000000000000000000000000000000000..cdfc43f0d7c0258bc444c0fbef1a530c23f0f4d1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request_en.md @@ -0,0 +1,22 @@ +--- +name: Feature Request +about: Describe the new feature you would like to add with clear and detailed language +title: '' +labels: enhancement +assignees: '' + +--- + +**Routine Checks** + +[//]: # (Remove the space in the box and fill with an x) ++ [ ] I have confirmed there are no similar issues currently ++ [ ] I have confirmed I have upgraded to the latest version ++ [ ] I have thoroughly read the project README and confirmed the current version cannot meet my needs ++ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback ++ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly** + +**Feature Description** + +**Use Case** + diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md new file mode 100644 index 0000000000000000000000000000000000000000..7403f6c0020c3759a364aaac8ac76bec6babe6bb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -0,0 +1,15 @@ +### PR 类型 + +- [ ] Bug 修复 +- [ ] 新功能 +- [ ] 文档更新 +- [ ] 其他 + +### PR 是否包含破坏性更新? + +- [ ] 是 +- [ ] 否 + +### PR 描述 + +**请在下方详细描述您的 PR,包括目的、实现细节等。** diff --git a/.github/workflows/docker-image-alpha.yml b/.github/workflows/docker-image-alpha.yml new file mode 100644 index 0000000000000000000000000000000000000000..2a7d43ad53ffd39cf079fcae35c656d98d023f7b --- /dev/null +++ b/.github/workflows/docker-image-alpha.yml @@ -0,0 +1,151 @@ +name: Publish Docker image (alpha) + +on: + push: + branches: + - alpha + workflow_dispatch: + inputs: + name: + description: "reason" + required: false + +jobs: + build_single_arch: + name: Build & push (${{ matrix.arch }}) [native] + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + permissions: + packages: write + contents: read + steps: + - name: Check out (shallow) + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Determine alpha version + id: version + run: | + VERSION="alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" + echo "$VERSION" > VERSION + echo "value=$VERSION" >> $GITHUB_OUTPUT + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "Publishing version: $VERSION for ${{ matrix.arch }}" + + - name: Normalize GHCR repository + run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: | + calciumion/new-api + ghcr.io/${{ env.GHCR_REPOSITORY }} + + - name: Build & push single-arch (to both registries) + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + push: true + tags: | + calciumion/new-api:alpha-${{ matrix.arch }} + calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }} + ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }} + ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.version.outputs.value }}-${{ matrix.arch }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + sbom: false + + create_manifests: + name: Create multi-arch manifests (Docker Hub + GHCR) + needs: [build_single_arch] + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + steps: + - name: Check out (shallow) + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Normalize GHCR repository + run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + + - name: Determine alpha version + id: version + run: | + VERSION="alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" + echo "value=$VERSION" >> $GITHUB_OUTPUT + echo "VERSION=$VERSION" >> $GITHUB_ENV + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Create & push manifest (Docker Hub - alpha) + run: | + docker buildx imagetools create \ + -t calciumion/new-api:alpha \ + calciumion/new-api:alpha-amd64 \ + calciumion/new-api:alpha-arm64 + + - name: Create & push manifest (Docker Hub - versioned alpha) + run: | + docker buildx imagetools create \ + -t calciumion/new-api:${VERSION} \ + calciumion/new-api:${VERSION}-amd64 \ + calciumion/new-api:${VERSION}-arm64 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create & push manifest (GHCR - alpha) + run: | + docker buildx imagetools create \ + -t ghcr.io/${GHCR_REPOSITORY}:alpha \ + ghcr.io/${GHCR_REPOSITORY}:alpha-amd64 \ + ghcr.io/${GHCR_REPOSITORY}:alpha-arm64 + + - name: Create & push manifest (GHCR - versioned alpha) + run: | + docker buildx imagetools create \ + -t ghcr.io/${GHCR_REPOSITORY}:${VERSION} \ + ghcr.io/${GHCR_REPOSITORY}:${VERSION}-amd64 \ + ghcr.io/${GHCR_REPOSITORY}:${VERSION}-arm64 diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml new file mode 100644 index 0000000000000000000000000000000000000000..78517af0ee2dba5eac3fdb919b6612b6c3ad0251 --- /dev/null +++ b/.github/workflows/docker-image-arm64.yml @@ -0,0 +1,138 @@ +name: Publish Docker image (Multi Registries, native amd64+arm64) + +on: + push: + tags: + - '*' + +jobs: + build_single_arch: + name: Build & push (${{ matrix.arch }}) [native] + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + + permissions: + packages: write + contents: read + + steps: + - name: Check out (shallow) + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Resolve tag & write VERSION + run: | + git fetch --tags --force --depth=1 + TAG=${GITHUB_REF#refs/tags/} + echo "TAG=$TAG" >> $GITHUB_ENV + echo "$TAG" > VERSION + echo "Building tag: $TAG for ${{ matrix.arch }}" + + +# - name: Normalize GHCR repository +# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + +# - name: Log in to GHCR +# uses: docker/login-action@v3 +# with: +# registry: ghcr.io +# username: ${{ github.actor }} +# password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: | + calciumion/new-api +# ghcr.io/${{ env.GHCR_REPOSITORY }} + + - name: Build & push single-arch (to both registries) + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + push: true + tags: | + calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }} + calciumion/new-api:latest-${{ matrix.arch }} +# ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.TAG }}-${{ matrix.arch }} +# ghcr.io/${{ env.GHCR_REPOSITORY }}:latest-${{ matrix.arch }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + sbom: false + + create_manifests: + name: Create multi-arch manifests (Docker Hub) + needs: [build_single_arch] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Extract tag + run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV +# +# - name: Normalize GHCR repository +# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Create & push manifest (Docker Hub - version) + run: | + docker buildx imagetools create \ + -t calciumion/new-api:${TAG} \ + calciumion/new-api:${TAG}-amd64 \ + calciumion/new-api:${TAG}-arm64 + + - name: Create & push manifest (Docker Hub - latest) + run: | + docker buildx imagetools create \ + -t calciumion/new-api:latest \ + calciumion/new-api:latest-amd64 \ + calciumion/new-api:latest-arm64 + + # ---- GHCR ---- +# - name: Log in to GHCR +# uses: docker/login-action@v3 +# with: +# registry: ghcr.io +# username: ${{ github.actor }} +# password: ${{ secrets.GITHUB_TOKEN }} + +# - name: Create & push manifest (GHCR - version) +# run: | +# docker buildx imagetools create \ +# -t ghcr.io/${GHCR_REPOSITORY}:${TAG} \ +# ghcr.io/${GHCR_REPOSITORY}:${TAG}-amd64 \ +# ghcr.io/${GHCR_REPOSITORY}:${TAG}-arm64 +# +# - name: Create & push manifest (GHCR - latest) +# run: | +# docker buildx imagetools create \ +# -t ghcr.io/${GHCR_REPOSITORY}:latest \ +# ghcr.io/${GHCR_REPOSITORY}:latest-amd64 \ +# ghcr.io/${GHCR_REPOSITORY}:latest-arm64 diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml new file mode 100644 index 0000000000000000000000000000000000000000..20113e00fe6bc141b4c5e2fcc16515d1a644f7eb --- /dev/null +++ b/.github/workflows/electron-build.yml @@ -0,0 +1,141 @@ +name: Build Electron App + +on: + push: + tags: + - '*' # Triggers on version tags like v1.0.0 + - '!*-*' # Ignore pre-release tags like v1.0.0-beta + - '!*-alpha*' # Ignore alpha tags like v1.0.0-alpha + workflow_dispatch: # Allows manual triggering + +jobs: + build: + strategy: + matrix: + # os: [macos-latest, windows-latest] + os: [windows-latest] + + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '>=1.25.1' + + - name: Build frontend + env: + CI: "" + NODE_OPTIONS: "--max-old-space-size=4096" + run: | + cd web + bun install + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build + cd .. + + # - name: Build Go binary (macos/Linux) + # if: runner.os != 'Windows' + # run: | + # go mod download + # go build -ldflags "-s -w -X 'new-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api + + - name: Build Go binary (Windows) + if: runner.os == 'Windows' + run: | + go mod download + go build -ldflags "-s -w -X 'new-api/common.Version=$(git describe --tags)'" -o new-api.exe + + - name: Update Electron version + run: | + cd electron + VERSION=$(git describe --tags) + VERSION=${VERSION#v} # Remove 'v' prefix if present + # Convert to valid semver: take first 3 components and convert rest to prerelease format + # e.g., 0.9.3-patch.1 -> 0.9.3-patch.1 + if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$ ]]; then + MAJOR=${BASH_REMATCH[1]} + MINOR=${BASH_REMATCH[2]} + PATCH=${BASH_REMATCH[3]} + REST=${BASH_REMATCH[4]} + + VERSION="$MAJOR.$MINOR.$PATCH" + + # If there's extra content, append it without adding -dev + if [[ -n "$REST" ]]; then + VERSION="$VERSION$REST" + fi + fi + npm version $VERSION --no-git-tag-version --allow-same-version + + - name: Install Electron dependencies + run: | + cd electron + npm install + + # - name: Build Electron app (macOS) + # if: runner.os == 'macOS' + # run: | + # cd electron + # npm run build:mac + # env: + # CSC_IDENTITY_AUTO_DISCOVERY: false # Skip code signing + + - name: Build Electron app (Windows) + if: runner.os == 'Windows' + run: | + cd electron + npm run build:win + + # - name: Upload artifacts (macOS) + # if: runner.os == 'macOS' + # uses: actions/upload-artifact@v4 + # with: + # name: macos-build + # path: | + # electron/dist/*.dmg + # electron/dist/*.zip + + - name: Upload artifacts (Windows) + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: windows-build + path: | + electron/dist/*.exe + + release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: write + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + + - name: Upload to Release + uses: softprops/action-gh-release@v2 + with: + files: | + windows-build/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000000000000000000000000000000000..6a96102797fefb8f534038e574f7115f83e395a1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,142 @@ +name: Release (Linux, macOS, Windows) +permissions: + contents: write + +on: + workflow_dispatch: + inputs: + name: + description: 'reason' + required: false + push: + tags: + - '*' + - '!*-alpha*' + +jobs: + linux: + name: Linux Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Determine Version + run: | + VERSION=$(git describe --tags) + echo "VERSION=$VERSION" >> $GITHUB_ENV + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Build Frontend + env: + CI: "" + run: | + cd web + bun install + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build + cd .. + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '>=1.25.1' + - name: Build Backend (amd64) + run: | + go mod download + go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION + - name: Build Backend (arm64) + run: | + sudo apt-get update + DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu + CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION + - name: Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + files: | + new-api-* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + macos: + name: macOS Release + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Determine Version + run: | + VERSION=$(git describe --tags) + echo "VERSION=$VERSION" >> $GITHUB_ENV + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Build Frontend + env: + CI: "" + NODE_OPTIONS: "--max-old-space-size=4096" + run: | + cd web + bun install + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build + cd .. + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '>=1.25.1' + - name: Build Backend + run: | + go mod download + go build -ldflags "-X 'new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION + - name: Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + files: new-api-macos-* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + windows: + name: Windows Release + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Determine Version + run: | + VERSION=$(git describe --tags) + echo "VERSION=$VERSION" >> $GITHUB_ENV + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Build Frontend + env: + CI: "" + run: | + cd web + bun install + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build + cd .. + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '>=1.25.1' + - name: Build Backend + run: | + go mod download + go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe + - name: Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + files: new-api-*.exe + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sync-to-gitee.yml b/.github/workflows/sync-to-gitee.yml new file mode 100644 index 0000000000000000000000000000000000000000..4f515a188dbed29539c5ff86756535893487ba37 --- /dev/null +++ b/.github/workflows/sync-to-gitee.yml @@ -0,0 +1,91 @@ +name: Sync Release to Gitee + +permissions: + contents: read + +on: + workflow_dispatch: + inputs: + tag_name: + description: 'Release Tag to sync (e.g. v1.0.0)' + required: true + type: string + +# 配置你的 Gitee 仓库信息 +env: + GITEE_OWNER: 'QuantumNous' # 修改为你的 Gitee 用户名 + GITEE_REPO: 'new-api' # 修改为你的 Gitee 仓库名 + +jobs: + sync-to-gitee: + runs-on: sync + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Get Release Info + id: release_info + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.event.inputs.tag_name }} + run: | + # 获取 release 信息 + RELEASE_INFO=$(gh release view "$TAG_NAME" --json name,body,tagName,targetCommitish) + + RELEASE_NAME=$(echo "$RELEASE_INFO" | jq -r '.name') + TARGET_COMMITISH=$(echo "$RELEASE_INFO" | jq -r '.targetCommitish') + + # 使用多行字符串输出 + { + echo "release_name=$RELEASE_NAME" + echo "target_commitish=$TARGET_COMMITISH" + echo "release_body<> $GITHUB_OUTPUT + + # 下载 release 的所有附件 + gh release download "$TAG_NAME" --dir ./release_assets || echo "No assets to download" + + # 列出下载的文件 + ls -la ./release_assets/ || echo "No assets directory" + + - name: Create Gitee Release + id: create_release + uses: nICEnnnnnnnLee/action-gitee-release@v2.0.0 + with: + gitee_action: create_release + gitee_owner: ${{ env.GITEE_OWNER }} + gitee_repo: ${{ env.GITEE_REPO }} + gitee_token: ${{ secrets.GITEE_TOKEN }} + gitee_tag_name: ${{ github.event.inputs.tag_name }} + gitee_release_name: ${{ steps.release_info.outputs.release_name }} + gitee_release_body: ${{ steps.release_info.outputs.release_body }} + gitee_target_commitish: ${{ steps.release_info.outputs.target_commitish }} + + - name: Upload Assets to Gitee + if: hashFiles('release_assets/*') != '' + uses: nICEnnnnnnnLee/action-gitee-release@v2.0.0 + with: + gitee_action: upload_asset + gitee_owner: ${{ env.GITEE_OWNER }} + gitee_repo: ${{ env.GITEE_REPO }} + gitee_token: ${{ secrets.GITEE_TOKEN }} + gitee_release_id: ${{ steps.create_release.outputs.release-id }} + gitee_upload_retry_times: 3 + gitee_files: | + release_assets/* + + - name: Cleanup + if: always() + run: | + rm -rf release_assets/ + + - name: Summary + if: success() + run: | + echo "✅ Successfully synced release ${{ github.event.inputs.tag_name }} to Gitee!" + echo "🔗 Gitee Release URL: https://gitee.com/${{ env.GITEE_OWNER }}/${{ env.GITEE_REPO }}/releases/tag/${{ github.event.inputs.tag_name }}" + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a86135f8794a9c63b7c5264fb46cc5cf36d63fd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +.idea +.vscode +.zed +upload +*.exe +*.db +build +*.db-journal +logs +web/dist +.env +one-api +new-api +/__debug_bin* +.DS_Store +tiktoken_cache +.eslintcache +.gocache +.cache +web/bun.lock + +electron/node_modules +electron/dist diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..67ae47232b51e401dcdaee840f35021385716f25 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +FROM oven/bun:latest AS builder + +WORKDIR /build +COPY web/package.json . +COPY web/bun.lock . +RUN bun install +COPY ./web . +COPY ./VERSION . +RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build + +FROM golang:alpine AS builder2 +ENV GO111MODULE=on CGO_ENABLED=0 + +ARG TARGETOS +ARG TARGETARCH +ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} + +WORKDIR /build + +ADD go.mod go.sum ./ +RUN go mod download + +COPY . . +COPY --from=builder /build/dist ./web/dist +RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api + +FROM alpine + +RUN apk upgrade --no-cache \ + && apk add --no-cache ca-certificates tzdata \ + && update-ca-certificates + +# Create a non-root user for HuggingFace Spaces +RUN adduser -D -u 1000 user +USER user + +COPY --from=builder2 /build/new-api /new-api + +# HuggingFace Spaces requires port 7860 +ENV PORT=7860 +EXPOSE 7860 + +WORKDIR /home/user/data +ENTRYPOINT ["/new-api"] diff --git a/Dockerfile.hf b/Dockerfile.hf new file mode 100644 index 0000000000000000000000000000000000000000..67ae47232b51e401dcdaee840f35021385716f25 --- /dev/null +++ b/Dockerfile.hf @@ -0,0 +1,44 @@ +FROM oven/bun:latest AS builder + +WORKDIR /build +COPY web/package.json . +COPY web/bun.lock . +RUN bun install +COPY ./web . +COPY ./VERSION . +RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build + +FROM golang:alpine AS builder2 +ENV GO111MODULE=on CGO_ENABLED=0 + +ARG TARGETOS +ARG TARGETARCH +ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} + +WORKDIR /build + +ADD go.mod go.sum ./ +RUN go mod download + +COPY . . +COPY --from=builder /build/dist ./web/dist +RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api + +FROM alpine + +RUN apk upgrade --no-cache \ + && apk add --no-cache ca-certificates tzdata \ + && update-ca-certificates + +# Create a non-root user for HuggingFace Spaces +RUN adduser -D -u 1000 user +USER user + +COPY --from=builder2 /build/new-api /new-api + +# HuggingFace Spaces requires port 7860 +ENV PORT=7860 +EXPOSE 7860 + +WORKDIR /home/user/data +ENTRYPOINT ["/new-api"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..71284f6d08247ca4ab7dec31cece50ca1fd08901 --- /dev/null +++ b/LICENSE @@ -0,0 +1,103 @@ +# **New API 许可协议 (Licensing)** + +本项目采用**基于使用场景的双重许可 (Usage-Based Dual Licensing)** 模式。 + +**核心原则:** + +- **默认许可:** 本项目默认在 **GNU Affero 通用公共许可证 v3.0 (AGPLv3)** 下提供。任何用户在遵守 AGPLv3 条款和下述附加限制的前提下,均可免费使用。 +- **商业许可:** 在特定商业场景下,或当您希望获得 AGPLv3 之外的权利时,**必须**获取**商业许可证 (Commercial License)**。 + +--- + +## **1. 开源许可证 (Open Source License): AGPLv3 - 适用于基础使用** + +- 在遵守 **AGPLv3** 条款的前提下,您可以自由地使用、修改和分发 New API。AGPLv3 的完整文本可以访问 [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html) 获取。 +- **核心义务:** AGPLv3 的一个关键要求是,如果您修改了 New API 并通过网络提供服务 (SaaS),或者分发了修改后的版本,您必须以 AGPLv3 许可证向所有用户提供相应的**完整源代码**。 +- **附加限制 (重要):** 在仅使用 AGPLv3 开源许可证的情况下,您**必须**完整保留项目代码中原有的品牌标识、LOGO 及版权声明信息。**禁止以任何形式修改、移除或遮盖**这些信息。如需移除,必须获取商业许可证。 +- 使用前请务必仔细阅读并理解 AGPLv3 的所有条款及上述附加限制。 + +## **2. 商业许可证 (Commercial License) - 适用于高级场景及闭源需求** + +在以下任一情况下,您**必须**联系我们获取并签署一份商业许可证,才能合法使用 New API: + +- **场景一:移除品牌和版权信息** + 您希望在您的产品或服务中移除 New API 的 LOGO、UI界面中的版权声明或其他品牌标识。 + +- **场景二:规避 AGPLv3 开源义务** + 您基于 New API 进行了修改,并希望: + - 通过网络提供服务(SaaS),但**不希望**向您的服务用户公开您修改后的源代码。 + - 分发一个集成了 New API 的软件产品,但**不希望**以 AGPLv3 许可证发布您的产品或公开源代码。 + +- **场景三:企业政策与集成需求** + - 您所在公司的政策、客户合同或项目要求不允许使用 AGPLv3 许可的软件。 + - 您需要进行 OEM 集成,将 New API 作为您闭源商业产品的一部分进行再分发。 + +- **场景四:需要商业支持与保障** + 您需要 AGPLv3 未提供的商业保障,如官方技术支持等。 + +**获取商业许可:** +请通过电子邮件 **support@quantumnous.com** 联系 New API 团队洽谈商业授权事宜。 + +## **3. 贡献 (Contributions)** + +- 我们欢迎社区对 New API 的贡献。所有向本项目提交的贡献(例如通过 Pull Request)都将被视为在 **AGPLv3** 许可证下提供。 +- 通过向本项目提交贡献,即表示您同意您的代码以 AGPLv3 许可证授权给本项目及所有后续使用者(无论这些使用者最终遵循 AGPLv3 还是商业许可)。 +- 您也理解并同意,您的贡献可能会被包含在根据商业许可证分发的 New API 版本中。 + +## **4. 其他条款 (Other Terms)** + +- 关于商业许可证的具体条款、条件和价格,以双方签署的正式商业许可协议为准。 +- 项目维护者保留根据需要更新本许可政策的权利。相关更新将通过项目官方渠道(如代码仓库、官方网站)进行通知。 + +--- + +# **New API Licensing** + +This project uses a **Usage-Based Dual Licensing** model. + +**Core Principles:** + +- **Default License:** This project is available by default under the **GNU Affero General Public License v3.0 (AGPLv3)**. Any user may use it free of charge, provided they comply with both the AGPLv3 terms and the additional restrictions listed below. +- **Commercial License:** For specific commercial scenarios, or if you require rights beyond those granted by AGPLv3, you **must** obtain a **Commercial License**. + +--- + +## **1. Open Source License: AGPLv3 – For Basic Usage** + +- Under the terms of the **AGPLv3**, you are free to use, modify, and distribute New API. The complete AGPLv3 license text can be viewed at [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html). +- **Core Obligation:** A key AGPLv3 requirement is that if you modify New API and provide it as a network service (SaaS), or distribute a modified version, you must make the **complete corresponding source code** available to all users under the AGPLv3 license. +- **Additional Restriction (Important):** When using only the AGPLv3 open-source license, you **must** retain all original branding, logos, and copyright statements within the project’s code. **You are strictly prohibited from modifying, removing, or concealing** any such information. If you wish to remove this, you must obtain a Commercial License. +- Please read and ensure that you fully understand all AGPLv3 terms and the above additional restriction before use. + +## **2. Commercial License – For Advanced Scenarios & Closed Source Needs** + +You **must** contact us to obtain and sign a Commercial License in any of the following scenarios in order to legally use New API: + +- **Scenario 1: Removal of Branding and Copyright** + You wish to remove the New API logo, copyright statement, or other branding elements from your product or service. + +- **Scenario 2: Avoidance of AGPLv3 Open Source Obligations** + You have modified New API and wish to: + - Offer it as a network service (SaaS) **without** disclosing your modifications' source code to your users. + - Distribute a software product integrated with New API **without** releasing your product under AGPLv3 or open-sourcing the code. + +- **Scenario 3: Enterprise Policy & Integration Needs** + - Your organization’s policies, client contracts, or project requirements prohibit the use of AGPLv3-licensed software. + - You require OEM integration and need to redistribute New API as part of your closed-source commercial product. + +- **Scenario 4: Commercial Support and Assurances** + You require commercial assurances not provided by AGPLv3, such as official technical support. + +**Obtaining a Commercial License:** +Please contact the New API team via email at **support@quantumnous.com** to discuss commercial licensing. + +## **3. Contributions** + +- We welcome community contributions to New API. All contributions (e.g., via Pull Request) are deemed to be provided under the **AGPLv3** license. +- By submitting a contribution, you agree that your code is licensed to this project and all downstream users under the AGPLv3 license (regardless of whether those users ultimately operate under AGPLv3 or a Commercial License). +- You also acknowledge and agree that your contribution may be included in New API releases distributed under a Commercial License. + +## **4. Other Terms** + +- The specific terms, conditions, and pricing of the Commercial License are governed by the formal commercial license agreement executed by both parties. +- Project maintainers reserve the right to update this licensing policy as needed. Updates will be communicated via official project channels (e.g., repository, official website). diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000000000000000000000000000000000000..55e2ffe5b97ba76dbd1fbe40a050107565c8d665 --- /dev/null +++ b/README.en.md @@ -0,0 +1,447 @@ +
+ +![new-api](/web/public/logo.png) + +# New API + +🍥 **Next-Generation Large Model Gateway and AI Asset Management System** + +

+ 中文 | + English | + Français | + 日本語 +

+ +

+ + license + + + release + + + docker + + + docker + + + GoReportCard + +

+ +

+ + Calcium-Ion%2Fnew-api | Trendshift + +

+ +

+ Quick Start • + Key Features • + Deployment • + Documentation • + Help +

+ +
+ +## 📝 Project Description + +> [!NOTE] +> This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api) + +> [!IMPORTANT] +> - This project is for personal learning purposes only, with no guarantee of stability or technical support +> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes +> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China. + +--- + +## 🤝 Trusted Partners + +

+ No particular order +

+ +

+ + Cherry Studio + + + Peking University + + + UCloud + + + Alibaba Cloud + + + IO.NET + +

+ +--- + +## 🙏 Special Thanks + +

+ + JetBrains Logo + +

+ +

+ Thanks to JetBrains for providing free open-source development license for this project +

+ +--- + +## 🚀 Quick Start + +### Using Docker Compose (Recommended) + +```bash +# Clone the project +git clone https://github.com/QuantumNous/new-api.git +cd new-api + +# Edit docker-compose.yml configuration +nano docker-compose.yml + +# Start the service +docker-compose up -d +``` + +
+Using Docker Commands + +```bash +# Pull the latest image +docker pull calciumion/new-api:latest + +# Using SQLite (default) +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest + +# Using MySQL +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest +``` + +> **💡 Tip:** `-v ./data:/data` will save data in the `data` folder of the current directory, you can also change it to an absolute path like `-v /your/custom/path:/data` + +
+ +--- + +🎉 After deployment is complete, visit `http://localhost:3000` to start using! + +📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/installation) + +--- + +## 📚 Documentation + +
+ +### 📖 [Official Documentation](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) + +
+ +**Quick Navigation:** + +| Category | Link | +|------|------| +| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/installation) | +| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/installation/environment-variables) | +| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/api) | +| ❓ FAQ | [FAQ](https://docs.newapi.pro/support/faq) | +| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/support/community-interaction) | + +--- + +## ✨ Key Features + +> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction) + +### 🎨 Core Functions + +| Feature | Description | +|------|------| +| 🎨 New UI | Modern user interface design | +| 🌍 Multi-language | Supports Chinese, English, French, Japanese | +| 🔄 Data Compatibility | Fully compatible with the original One API database | +| 📈 Data Dashboard | Visual console and statistical analysis | +| 🔒 Permission Management | Token grouping, model restrictions, user management | + +### 💰 Payment and Billing + +- ✅ Online recharge (EPay, Stripe) +- ✅ Pay-per-use model pricing +- ✅ Cache billing support (OpenAI, Azure, DeepSeek, Claude, Qwen and all supported models) +- ✅ Flexible billing policy configuration + +### 🔐 Authorization and Security + +- 😈 Discord authorization login +- 🤖 LinuxDO authorization login +- 📱 Telegram authorization login +- 🔑 OIDC unified authentication + +### 🚀 Advanced Features + +**API Format Support:** +- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses) +- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) (including Azure) +- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat) +- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/) +- 🔄 [Rerank Models](https://docs.newapi.pro/api/jinaai-rerank) (Cohere, Jina) + +**Intelligent Routing:** +- ⚖️ Channel weighted random +- 🔄 Automatic retry on failure +- 🚦 User-level model rate limiting + +**Format Conversion:** +- 🔄 OpenAI ⇄ Claude Messages +- 🔄 OpenAI ⇄ Gemini Chat +- 🔄 Thinking-to-content functionality + +**Reasoning Effort Support:** + +
+View detailed configuration + +**OpenAI series models:** +- `o3-mini-high` - High reasoning effort +- `o3-mini-medium` - Medium reasoning effort +- `o3-mini-low` - Low reasoning effort +- `gpt-5-high` - High reasoning effort +- `gpt-5-medium` - Medium reasoning effort +- `gpt-5-low` - Low reasoning effort + +**Claude thinking models:** +- `claude-3-7-sonnet-20250219-thinking` - Enable thinking mode + +**Google Gemini series models:** +- `gemini-2.5-flash-thinking` - Enable thinking mode +- `gemini-2.5-flash-nothinking` - Disable thinking mode +- `gemini-2.5-pro-thinking` - Enable thinking mode +- `gemini-2.5-pro-thinking-128` - Enable thinking mode with thinking budget of 128 tokens + +
+ +--- + +## 🤖 Model Support + +> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/api) + +| Model Type | Description | Documentation | +|---------|------|------| +| 🤖 OpenAI GPTs | gpt-4-gizmo-* series | - | +| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/api/midjourney-proxy-image) | +| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/api/suno-music) | +| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/api/jinaai-rerank) | +| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/api/anthropic-chat) | +| 🌐 Gemini | Google Gemini format | [Documentation](https://docs.newapi.pro/api/google-gemini-chat/) | +| 🔧 Dify | ChatFlow mode | - | +| 🎯 Custom | Supports complete call address | - | + +### 📡 Supported Interfaces + +
+View complete interface list + +- [Chat Interface (Chat Completions)](https://docs.newapi.pro/api/openai-chat) +- [Response Interface (Responses)](https://docs.newapi.pro/api/openai-responses) +- [Image Interface (Image)](https://docs.newapi.pro/api/openai-image) +- [Audio Interface (Audio)](https://docs.newapi.pro/api/openai-audio) +- [Video Interface (Video)](https://docs.newapi.pro/api/openai-video) +- [Embedding Interface (Embeddings)](https://docs.newapi.pro/api/openai-embeddings) +- [Rerank Interface (Rerank)](https://docs.newapi.pro/api/jinaai-rerank) +- [Realtime Conversation (Realtime)](https://docs.newapi.pro/api/openai-realtime) +- [Claude Chat](https://docs.newapi.pro/api/anthropic-chat) +- [Google Gemini Chat](https://docs.newapi.pro/api/google-gemini-chat/) + +
+ +--- + +## 🚢 Deployment + +> [!TIP] +> **Latest Docker image:** `calciumion/new-api:latest` + +### 📋 Deployment Requirements + +| Component | Requirement | +|------|------| +| **Local database** | SQLite (Docker must mount `/data` directory)| +| **Remote database** | MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 | +| **Container engine** | Docker / Docker Compose | + +### ⚙️ Environment Variable Configuration + +
+Common environment variable configuration + +| Variable Name | Description | Default Value | +|--------|------|--------| +| `SESSION_SECRET` | Session secret (required for multi-machine deployment) | - | +| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - | +| `SQL_DSN` | Database connection string | - | +| `REDIS_CONN_STRING` | Redis connection string | - | +| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` | +| `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` | +| `ERROR_LOG_ENABLED` | Error log switch | `false` | + +📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/installation/environment-variables) + +
+ +### 🔧 Deployment Methods + +
+Method 1: Docker Compose (Recommended) + +```bash +# Clone the project +git clone https://github.com/QuantumNous/new-api.git +cd new-api + +# Edit configuration +nano docker-compose.yml + +# Start service +docker-compose up -d +``` + +
+ +
+Method 2: Docker Commands + +**Using SQLite:** +```bash +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest +``` + +**Using MySQL:** +```bash +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest +``` + +> **💡 Path explanation:** +> - `./data:/data` - Relative path, data saved in the data folder of the current directory +> - You can also use absolute path, e.g.: `/your/custom/path:/data` + +
+ +
+Method 3: BaoTa Panel + +1. Install BaoTa Panel (≥ 9.2.0 version) +2. Search for **New-API** in the application store +3. One-click installation + +📖 [Tutorial with images](./docs/BT.md) + +
+ +### ⚠️ Multi-machine Deployment Considerations + +> [!WARNING] +> - **Must set** `SESSION_SECRET` - Otherwise login status inconsistent +> - **Shared Redis must set** `CRYPTO_SECRET` - Otherwise data cannot be decrypted + +### 🔄 Channel Retry and Cache + +**Retry configuration:** `Settings → Operation Settings → General Settings → Failure Retry Count` + +**Cache configuration:** +- `REDIS_CONN_STRING`: Redis cache (recommended) +- `MEMORY_CACHE_ENABLED`: Memory cache + +--- + +## 🔗 Related Projects + +### Upstream Projects + +| Project | Description | +|------|------| +| [One API](https://github.com/songquanpeng/one-api) | Original project base | +| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourney interface support | + +### Supporting Tools + +| Project | Description | +|------|------| +| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key quota query tool | +| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API high-performance optimized version | + +--- + +## 💬 Help Support + +### 📖 Documentation Resources + +| Resource | Link | +|------|------| +| 📘 FAQ | [FAQ](https://docs.newapi.pro/support/faq) | +| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/support/community-interaction) | +| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/support/feedback-issues) | +| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/support) | + +### 🤝 Contribution Guide + +Welcome all forms of contribution! + +- 🐛 Report Bugs +- 💡 Propose New Features +- 📝 Improve Documentation +- 🔧 Submit Code + +--- + +## 🌟 Star History + +
+ +[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date) + +
+ +--- + +
+ +### 💖 Thank you for using New API + +If this project is helpful to you, welcome to give us a ⭐️ Star! + +**[Official Documentation](https://docs.newapi.pro/)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)** + +Built with ❤️ by QuantumNous + +
diff --git a/README.fr.md b/README.fr.md new file mode 100644 index 0000000000000000000000000000000000000000..a941c50db6b2e687de419fce5983eca8d544f7a9 --- /dev/null +++ b/README.fr.md @@ -0,0 +1,441 @@ +
+ +![new-api](/web/public/logo.png) + +# New API + +🍥 **Passerelle de modèles étendus de nouvelle génération et système de gestion d'actifs d'IA** + +

+ 中文 | + English | + Français | + 日本語 +

+ +

+ + licence + + + version + + + docker + + + docker + + + GoReportCard + +

+ +

+ + Calcium-Ion%2Fnew-api | Trendshift + +

+ +

+ Démarrage rapide • + Fonctionnalités clés • + Déploiement • + Documentation • + Aide +

+ +
+ +## 📝 Description du projet + +> [!NOTE] +> Il s'agit d'un projet open-source développé sur la base de [One API](https://github.com/songquanpeng/one-api) + +> [!IMPORTANT] +> - Ce projet est uniquement destiné à des fins d'apprentissage personnel, sans garantie de stabilité ni de support technique. +> - Les utilisateurs doivent se conformer aux [Conditions d'utilisation](https://openai.com/policies/terms-of-use) d'OpenAI et aux **lois et réglementations applicables**, et ne doivent pas l'utiliser à des fins illégales. +> - Conformément aux [《Mesures provisoires pour la gestion des services d'intelligence artificielle générative》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), veuillez ne fournir aucun service d'IA générative non enregistré au public en Chine. + +--- + +## 🤝 Partenaires de confiance + +

+ Sans ordre particulier +

+ +

+ + Cherry Studio + + + Université de Pékin + + + UCloud + + + Alibaba Cloud + + + IO.NET + +

+ +--- + +## 🙏 Remerciements spéciaux + +

+ + JetBrains Logo + +

+ +

+ Merci à JetBrains pour avoir fourni une licence de développement open-source gratuite pour ce projet +

+ +--- + +## 🚀 Démarrage rapide + +### Utilisation de Docker Compose (recommandé) + +```bash +# Cloner le projet +git clone https://github.com/QuantumNous/new-api.git +cd new-api + +# Modifier la configuration docker-compose.yml +nano docker-compose.yml + +# Démarrer le service +docker-compose up -d +``` + +
+Utilisation des commandes Docker + +```bash +# Tirer la dernière image +docker pull calciumion/new-api:latest + +# Utilisation de SQLite (par défaut) +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest + +# Utilisation de MySQL +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest +``` + +> **💡 Astuce:** `-v ./data:/data` sauvegardera les données dans le dossier `data` du répertoire actuel, vous pouvez également le changer en chemin absolu comme `-v /your/custom/path:/data` + +
+ +--- + +🎉 Après le déploiement, visitez `http://localhost:3000` pour commencer à utiliser! + +📖 Pour plus de méthodes de déploiement, veuillez vous référer à [Guide de déploiement](https://docs.newapi.pro/installation) + +--- + +## 📚 Documentation + +
+ +### 📖 [Documentation officielle](https://docs.newapi.pro/) | [![Demander à DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) + +
+ +**Navigation rapide:** + +| Catégorie | Lien | +|------|------| +| 🚀 Guide de déploiement | [Documentation d'installation](https://docs.newapi.pro/installation) | +| ⚙️ Configuration de l'environnement | [Variables d'environnement](https://docs.newapi.pro/installation/environment-variables) | +| 📡 Documentation de l'API | [Documentation de l'API](https://docs.newapi.pro/api) | +| ❓ FAQ | [FAQ](https://docs.newapi.pro/support/faq) | +| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/support/community-interaction) | + +--- + +## ✨ Fonctionnalités clés + +> Pour les fonctionnalités détaillées, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/wiki/features-introduction) | + +### 🎨 Fonctions principales + +| Fonctionnalité | Description | +|------|------| +| 🎨 Nouvelle interface utilisateur | Conception d'interface utilisateur moderne | +| 🌍 Multilingue | Prend en charge le chinois, l'anglais, le français, le japonais | +| 🔄 Compatibilité des données | Complètement compatible avec la base de données originale de One API | +| 📈 Tableau de bord des données | Console visuelle et analyse statistique | +| 🔒 Gestion des permissions | Regroupement de jetons, restrictions de modèles, gestion des utilisateurs | + +### 💰 Paiement et facturation + +- ✅ Recharge en ligne (EPay, Stripe) +- ✅ Tarification des modèles de paiement à l'utilisation +- ✅ Prise en charge de la facturation du cache (OpenAI, Azure, DeepSeek, Claude, Qwen et tous les modèles pris en charge) +- ✅ Configuration flexible des politiques de facturation + +### 🔐 Autorisation et sécurité + +- 🤖 Connexion par autorisation LinuxDO +- 📱 Connexion par autorisation Telegram +- 🔑 Authentification unifiée OIDC + +### 🚀 Fonctionnalités avancées + +**Prise en charge des formats d'API:** +- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses) +- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) (y compris Azure) +- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat) +- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/) +- 🔄 [Modèles Rerank](https://docs.newapi.pro/api/jinaai-rerank) (Cohere, Jina) + +**Routage intelligent:** +- ⚖️ Sélection aléatoire pondérée des canaux +- 🔄 Nouvelle tentative automatique en cas d'échec +- 🚦 Limitation du débit du modèle pour les utilisateurs + +**Conversion de format:** +- 🔄 OpenAI ⇄ Claude Messages +- 🔄 OpenAI ⇄ Gemini Chat +- 🔄 Fonctionnalité de la pensée au contenu + +**Prise en charge de l'effort de raisonnement:** + +
+Voir la configuration détaillée + +**Modèles de la série o d'OpenAI:** +- `o3-mini-high` - Effort de raisonnement élevé +- `o3-mini-medium` - Effort de raisonnement moyen +- `o3-mini-low` - Effort de raisonnement faible + +**Modèles de pensée de Claude:** +- `claude-3-7-sonnet-20250219-thinking` - Activer le mode de pensée + +**Modèles de la série Google Gemini:** +- `gemini-2.5-flash-thinking` - Activer le mode de pensée +- `gemini-2.5-flash-nothinking` - Désactiver le mode de pensée +- `gemini-2.5-pro-thinking` - Activer le mode de pensée +- `gemini-2.5-pro-thinking-128` - Activer le mode de pensée avec budget de pensée de 128 tokens + +
+ +--- + +## 🤖 Prise en charge des modèles + +> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de relais](https://docs.newapi.pro/api) + +| Type de modèle | Description | Documentation | +|---------|------|------| +| 🤖 OpenAI GPTs | série gpt-4-gizmo-* | - | +| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/api/midjourney-proxy-image) | +| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/api/suno-music) | +| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/api/jinaai-rerank) | +| 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/api/anthropic-chat) | +| 🌐 Gemini | Format Google Gemini | [Documentation](https://docs.newapi.pro/api/google-gemini-chat/) | +| 🔧 Dify | Mode ChatFlow | - | +| 🎯 Personnalisé | Prise en charge de l'adresse d'appel complète | - | + +### 📡 Interfaces prises en charge + +
+Voir la liste complète des interfaces + +- [Interface de discussion (Chat Completions)](https://docs.newapi.pro/api/openai-chat) +- [Interface de réponse (Responses)](https://docs.newapi.pro/api/openai-responses) +- [Interface d'image (Image)](https://docs.newapi.pro/api/openai-image) +- [Interface audio (Audio)](https://docs.newapi.pro/api/openai-audio) +- [Interface vidéo (Video)](https://docs.newapi.pro/api/openai-video) +- [Interface d'incorporation (Embeddings)](https://docs.newapi.pro/api/openai-embeddings) +- [Interface de rerank (Rerank)](https://docs.newapi.pro/api/jinaai-rerank) +- [Conversation en temps réel (Realtime)](https://docs.newapi.pro/api/openai-realtime) +- [Discussion Claude](https://docs.newapi.pro/api/anthropic-chat) +- [Discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/) + +
+ +--- + +## 🚢 Déploiement + +> [!TIP] +> **Dernière image Docker:** `calciumion/new-api:latest` + +### 📋 Exigences de déploiement + +| Composant | Exigence | +|------|------| +| **Base de données locale** | SQLite (Docker doit monter le répertoire `/data`)| +| **Base de données distante | MySQL ≥ 5.7.8 ou PostgreSQL ≥ 9.6 | +| **Moteur de conteneur** | Docker / Docker Compose | + +### ⚙️ Configuration des variables d'environnement + +
+Configuration courante des variables d'environnement + +| Nom de variable | Description | Valeur par défaut | +|--------|------|--------| +| `SESSION_SECRET` | Secret de session (requis pour le déploiement multi-machines) | +| `CRYPTO_SECRET` | Secret de chiffrement (requis pour Redis) | - | +| `SQL_DSN` | Chaine de connexion à la base de données | - | +| `REDIS_CONN_STRING` | Chaine de connexion Redis | - | +| `STREAMING_TIMEOUT` | Délai d'expiration du streaming (secondes) | `300` | +| `AZURE_DEFAULT_API_VERSION` | Version de l'API Azure | `2025-04-01-preview` | +| `ERROR_LOG_ENABLED` | Interrupteur du journal d'erreurs | `false` | + +📖 **Configuration complète:** [Documentation des variables d'environnement](https://docs.newapi.pro/installation/environment-variables) + +
+ +### 🔧 Méthodes de déploiement + +
+Méthode 1: Docker Compose (recommandé) + +```bash +# Cloner le projet +git clone https://github.com/QuantumNous/new-api.git +cd new-api + +# Modifier la configuration +nano docker-compose.yml + +# Démarrer le service +docker-compose up -d +``` + +
+ +
+Méthode 2: Commandes Docker + +**Utilisation de SQLite:** +```bash +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest +``` + +**Utilisation de MySQL:** +```bash +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest +``` + +> **💡 Explication du chemin:** +> - `./data:/data` - Chemin relatif, données sauvegardées dans le dossier data du répertoire actuel +> - Vous pouvez également utiliser un chemin absolu, par exemple : `/your/custom/path:/data` + +
+ +
+Méthode 3: Panneau BaoTa + +1. Installez le panneau BaoTa (version **9.2.0** ou supérieure), recherchez **New-API** dans le magasin d'applications et installez-le. +2. Recherchez **New-API** dans le magasin d'applications et installez-le. + +📖 [Tutoriel avec des images](./docs/BT.md) + +
+ +### ⚠️ Considérations sur le déploiement multi-machines + +> [!WARNING] +> - **Doit définir** `SESSION_SECRET` - Sinon l'état de connexion sera incohérent sur plusieurs machines +> - **Redis partagé doit définir** `CRYPTO_SECRET` - Sinon les données ne pourront pas être déchiffrées + +### 🔄 Nouvelle tentative de canal et cache + +**Configuration de la nouvelle tentative:** `Paramètres → Paramètres de fonctionnement → Paramètres généraux → Nombre de tentatives en cas d'échec` + +**Configuration du cache:** +- `REDIS_CONN_STRING`: Cache Redis (recommandé) +- `MEMORY_CACHE_ENABLED`: Cache mémoire + +--- + +## 🔗 Projets connexes + +### Projets en amont + +| Projet | Description | +|------|------| +| [One API](https://github.com/songquanpeng/one-api) | Base du projet original | +| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Prise en charge de l'interface Midjourney | + +### Outils d'accompagnement + +| Projet | Description | +|------|------| +| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Outil de recherche de quota d'utilisation avec une clé | + +--- + +## 💬 Aide et support + +### 📖 Ressources de documentation + +| Ressource | Lien | +|------|------| +| 📘 FAQ | [FAQ](https://docs.newapi.pro/support/faq) | +| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/support/community-interaction) | +| 🐛 Commentaires sur les problèmes | [Commentaires sur les problèmes](https://docs.newapi.pro/support/feedback-issues) | +| 📚 Documentation complète | [Documentation officielle](https://docs.newapi.pro/support) | + +### 🤝 Guide de contribution + +Bienvenue à toutes les formes de contribution! + +- 🐛 Signaler des bogues +- 💡 Proposer de nouvelles fonctionnalités +- 📝 Améliorer la documentation +- 🔧 Soumettre du code + +--- + +## 🌟 Historique des étoiles + +
+ +[![Graphique de l'historique des étoiles](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date) + +
+ +--- + +
+ +### 💖 Merci d'utiliser New API + +Si ce projet vous est utile, bienvenue à nous donner une ⭐️ Étoile! + +**[Documentation officielle](https://docs.newapi.pro/)** • **[Commentaires sur les problèmes](https://github.com/Calcium-Ion/new-api/issues)** • **[Dernière version](https://github.com/Calcium-Ion/new-api/releases)** + +Construit avec ❤️ par QuantumNous + +
\ No newline at end of file diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 0000000000000000000000000000000000000000..928e190f5a90355be08a4c3564b9927b2ca9f278 --- /dev/null +++ b/README.ja.md @@ -0,0 +1,450 @@ +
+ +![new-api](/web/public/logo.png) + +# New API + +🍥 **次世代大規模モデルゲートウェイとAI資産管理システム** + +

+ 中文 | + English | + Français | + 日本語 +

+ +

+ + license + + + release + + + docker + + + docker + + + GoReportCard + +

+ +

+ + Calcium-Ion%2Fnew-api | Trendshift + +

+ +

+ クイックスタート • + 主な機能 • + デプロイ • + ドキュメント • + ヘルプ +

+ +
+ +## 📝 プロジェクト説明 + +> [!NOTE] +> 本プロジェクトは、[One API](https://github.com/songquanpeng/one-api)をベースに二次開発されたオープンソースプロジェクトです + +> [!IMPORTANT] +> - 本プロジェクトは個人学習用のみであり、安定性の保証や技術サポートは提供しません。 +> - ユーザーは、OpenAIの[利用規約](https://openai.com/policies/terms-of-use)および**法律法規**を遵守する必要があり、違法な目的で使用してはいけません。 +> - [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)の要求に従い、中国地域の公衆に未登録の生成式AI サービスを提供しないでください。 + +--- + +## 🤝 信頼できるパートナー + +

+ 順不同 +

+ +

+ + Cherry Studio + + + 北京大学 + + + UCloud 優刻得 + + + Alibaba Cloud + + + IO.NET + +

+ +--- + +## 🙏 特別な感謝 + +

+ + JetBrains Logo + +

+ +

+ 感謝 JetBrains が本プロジェクトに無料のオープンソース開発ライセンスを提供してくれたことに感謝します +

+ +--- + +## 🚀 クイックスタート + +### Docker Composeを使用(推奨) + +```bash +# プロジェクトをクローン +git clone https://github.com/QuantumNous/new-api.git +cd new-api + +# docker-compose.yml 設定を編集 +nano docker-compose.yml + +# サービスを起動 +docker-compose up -d +``` + +
+Dockerコマンドを使用 + +```bash +# 最新のイメージをプル +docker pull calciumion/new-api:latest + +# SQLiteを使用(デフォルト) +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest + +# MySQLを使用 +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest +``` + +> **💡 ヒント:** `-v ./data:/data` は現在のディレクトリの `data` フォルダにデータを保存します。絶対パスに変更することもできます:`-v /your/custom/path:/data` + +
+ +--- + +🎉 デプロイが完了したら、`http://localhost:3000` にアクセスして使用を開始してください! + +📖 その他のデプロイ方法については[デプロイガイド](https://docs.newapi.pro/installation)を参照してください。 + +--- + +## 📚 ドキュメント + +
+ +### 📖 [公式ドキュメント](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) + +
+ +**クイックナビゲーション:** + +| カテゴリ | リンク | +|------|------| +| 🚀 デプロイガイド | [インストールドキュメント](https://docs.newapi.pro/installation) | +| ⚙️ 環境設定 | [環境変数](https://docs.newapi.pro/installation/environment-variables) | +| 📡 APIドキュメント | [APIドキュメント](https://docs.newapi.pro/api) | +| ❓ よくある質問 | [FAQ](https://docs.newapi.pro/support/faq) | +| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/support/community-interaction) | + +--- + +## ✨ 主な機能 + +> 詳細な機能については[機能説明](https://docs.newapi.pro/wiki/features-introduction)を参照してください。 + +### 🎨 コア機能 + +| 機能 | 説明 | +|------|------| +| 🎨 新しいUI | モダンなユーザーインターフェースデザイン | +| 🌍 多言語 | 中国語、英語、フランス語、日本語をサポート | +| 🔄 データ互換性 | オリジナルのOne APIデータベースと完全に互換性あり | +| 📈 データダッシュボード | ビジュアルコンソールと統計分析 | +| 🔒 権限管理 | トークングループ化、モデル制限、ユーザー管理 | + +### 💰 支払いと課金 + +- ✅ オンライン充電(EPay、Stripe) +- ✅ モデルの従量課金 +- ✅ キャッシュ課金サポート(OpenAI、Azure、DeepSeek、Claude、Qwenなどすべてのサポートされているモデル) +- ✅ 柔軟な課金ポリシー設定 + +### 🔐 認証とセキュリティ + +- 🤖 LinuxDO認証ログイン +- 📱 Telegram認証ログイン +- 🔑 OIDC統一認証 + + + +### 🚀 高度な機能 + +**APIフォーマットサポート:** +- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses) +- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)(Azureを含む) +- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat) +- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/) +- 🔄 [Rerankモデル](https://docs.newapi.pro/api/jinaai-rerank) +- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) +- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat) +- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/) +- 🔄 [Rerankモデル](https://docs.newapi.pro/api/jinaai-rerank)(Cohere、Jina) + +**インテリジェントルーティング:** +- ⚖️ チャネル重み付けランダム +- 🔄 失敗自動リトライ +- 🚦 ユーザーレベルモデルレート制限 + +**フォーマット変換:** +- 🔄 OpenAI ⇄ Claude Messages +- 🔄 OpenAI ⇄ Gemini Chat +- 🔄 思考からコンテンツへの機能 + +**Reasoning Effort サポート:** + +
+詳細設定を表示 + +**OpenAIシリーズモデル:** +- `o3-mini-high` - 高思考努力 +- `o3-mini-medium` - 中思考努力 +- `o3-mini-low` - 低思考努力 +- `gpt-5-high` - 高思考努力 +- `gpt-5-medium` - 中思考努力 +- `gpt-5-low` - 低思考努力 + +**Claude思考モデル:** +- `claude-3-7-sonnet-20250219-thinking` - 思考モードを有効にする + +**Google Geminiシリーズモデル:** +- `gemini-2.5-flash-thinking` - 思考モードを有効にする +- `gemini-2.5-flash-nothinking` - 思考モードを無効にする +- `gemini-2.5-pro-thinking` - 思考モードを有効にする +- `gemini-2.5-pro-thinking-128` - 思考モードを有効にし、思考予算を128トークンに設定する + +
+ +--- + +## 🤖 モデルサポート + +> 詳細については[APIドキュメント - 中継インターフェース](https://docs.newapi.pro/api) + +| モデルタイプ | 説明 | ドキュメント | +|---------|------|------| +| 🤖 OpenAI GPTs | gpt-4-gizmo-* シリーズ | - | +| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [ドキュメント](https://docs.newapi.pro/api/midjourney-proxy-image) | +| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [ドキュメント](https://docs.newapi.pro/api/suno-music) | +| 🔄 Rerank | Cohere、Jina | [ドキュメント](https://docs.newapi.pro/api/jinaai-rerank) | +| 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/api/suno-music) | +| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://docs.newapi.pro/api/google-gemini-chat/) | +| 🔧 Dify | ChatFlowモード | - | +| 🎯 カスタム | 完全な呼び出しアドレスの入力をサポート | - | + +### 📡 サポートされているインターフェース + +
+完全なインターフェースリストを表示 + +- [チャットインターフェース (Chat Completions)](https://docs.newapi.pro/api/openai-chat) +- [レスポンスインターフェース (Responses)](https://docs.newapi.pro/api/openai-responses) +- [イメージインターフェース (Image)](https://docs.newapi.pro/api/openai-image) +- [オーディオインターフェース (Audio)](https://docs.newapi.pro/api/openai-audio) +- [ビデオインターフェース (Video)](https://docs.newapi.pro/api/openai-video) +- [エンベッドインターフェース (Embeddings)](https://docs.newapi.pro/api/openai-embeddings) +- [再ランク付けインターフェース (Rerank)](https://docs.newapi.pro/api/jinaai-rerank) +- [リアルタイム対話インターフェース (Realtime)](https://docs.newapi.pro/api/openai-realtime) +- [Claudeチャット](https://docs.newapi.pro/api/anthropic-chat) +- [Google Geminiチャット](https://docs.newapi.pro/api/google-gemini-chat/) + +
+ +--- + +## 🚢 デプロイ + +> [!TIP] +> **最新のDockerイメージ:** `calciumion/new-api:latest` + +### 📋 デプロイ要件 + +| コンポーネント | 要件 | +|------|------| +| **ローカルデータベース** | SQLite(Dockerは `/data` ディレクトリをマウントする必要があります)| +| **リモートデータベース** | MySQL ≥ 5.7.8 または PostgreSQL ≥ 9.6 | +| **コンテナエンジン** | Docker / Docker Compose | + +### ⚙️ 環境変数設定 + +
+一般的な環境変数設定 + +| 変数名 | 説明 | デフォルト値 | +|--------|------|--------| +| `SESSION_SECRET` | セッションシークレット(マルチマシンデプロイに必須) | - | +| `CRYPTO_SECRET` | 暗号化シークレット(Redisに必須) | - | +| `SQL_DSN** | データベース接続文字列 | - | +| `REDIS_CONN_STRING` | Redis接続文字列 | - | +| `STREAMING_TIMEOUT` | ストリーミング応答のタイムアウト時間(秒) | `300` | +| `AZURE_DEFAULT_API_VERSION` | Azure APIバージョン | `2025-04-01-preview` | +| `ERROR_LOG_ENABLED` | エラーログスイッチ | `false` | + +📖 **完全な設定:** [環境変数ドキュメント](https://docs.newapi.pro/installation/environment-variables) + +
+ +### 🔧 デプロイ方法 + +
+方法 1: Docker Compose(推奨) + +```bash +# プロジェクトをクローン +git clone https://github.com/QuantumNous/new-api.git +cd new-api + +# 設定を編集 +nano docker-compose.yml + +# サービスを起動 +docker-compose up -d +``` + +
+ +
+方法 2: Dockerコマンド + +**SQLiteを使用:** +```bash +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest +``` + +**MySQLを使用:** +```bash +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest +``` + +> **💡 パス説明:** +> - `./data:/data` - 相対パス、データは現在のディレクトリのdataフォルダに保存されます +> - 絶対パスを使用することもできます:`/your/custom/path:/data` + +
+ +
+方法 3: 宝塔パネル + +1. 宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を検索してインストールします。 + +📖 [画像付きチュートリアル](./docs/BT.md) + +
+ +### ⚠️ マルチマシンデプロイの注意事項 + +> [!WARNING] +> - **必ず設定する必要があります** `SESSION_SECRET` - そうしないとマルチマシンデプロイ時にログイン状態が不一致になります +> - **共有Redisは必ず設定する必要があります** `CRYPTO_SECRET` - そうしないとデータを復号化できません + +### 🔄 チャネルリトライとキャッシュ + +**リトライ設定:** `設定 → 運営設定 → 一般設定 → 失敗リトライ回数` + +**キャッシュ設定:** +- `REDIS_CONN_STRING`:Redisキャッシュ(推奨) +- `MEMORY_CACHE_ENABLED`:メモリキャッシュ + +--- + +## 🔗 関連プロジェクト + +### 上流プロジェクト + +| プロジェクト | 説明 | +|------|------| +| [One API](https://github.com/songquanpeng/one-api) | オリジナルプロジェクトベース | +| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourneyインターフェースサポート | + +### 補助ツール + +| プロジェクト | 説明 | +|------|------| +| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | キー使用量クォータ照会ツール | +| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API高性能最適化版 | + +--- + +## 💬 ヘルプサポート + +### 📖 ドキュメントリソース + +| リソース | リンク | +|------|------| +| 📘 よくある質問 | [FAQ](https://docs.newapi.pro/support/faq) | +| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/support/community-interaction) | +| 🐛 問題のフィードバック | [問題フィードバック](https://docs.newapi.pro/support/feedback-issues) | +| 📚 完全なドキュメント | [公式ドキュメント](https://docs.newapi.pro/support) | + +### 🤝 貢献ガイド + +あらゆる形の貢献を歓迎します! + +- 🐛 バグを報告する +- 💡 新しい機能を提案する +- 📝 ドキュメントを改善する +- 🔧 コードを提出する + +--- + +## 🌟 スター履歴 + +
+ +[![スター履歴チャート](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date) + +
+ +--- + +
+ +### 💖 New APIをご利用いただきありがとうございます + +このプロジェクトがあなたのお役に立てたなら、ぜひ ⭐️ スターをください! + +**[公式ドキュメント](https://docs.newapi.pro/)** • **[問題フィードバック](https://github.com/Calcium-Ion/new-api/issues)** • **[最新リリース](https://github.com/Calcium-Ion/new-api/releases)** + +❤️ で構築された QuantumNous + +
diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..70a1c0e51f165085b3f92cfae5d4626d6cb3f340 --- /dev/null +++ b/README.md @@ -0,0 +1,459 @@ +--- +title: New API +emoji: 🚀 +colorFrom: blue +colorTo: purple +sdk: docker +app_port: 7860 +pinned: false +license: mit +--- + +
+ +![new-api](/web/public/logo.png) + +# New API + +🍥 **新一代大模型网关与AI资产管理系统** + +

+ 中文 | + English | + Français | + 日本語 +

+ +

+ + license + + + release + + + docker + + + docker + + + GoReportCard + +

+ +

+ + Calcium-Ion%2Fnew-api | Trendshift + +

+ +

+ 快速开始 • + 主要特性 • + 部署 • + 文档 • + 帮助 +

+ +
+ +## 📝 项目说明 + +> [!NOTE] +> 本项目为开源项目,在 [One API](https://github.com/songquanpeng/one-api) 的基础上进行二次开发 + +> [!IMPORTANT] +> - 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持 +> - 使用者必须在遵循 OpenAI 的 [使用条款](https://openai.com/policies/terms-of-use) 以及**法律法规**的情况下使用,不得用于非法用途 +> - 根据 [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm) 的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务 + +--- + +## 🤝 我们信任的合作伙伴 + +

+ 排名不分先后 +

+ +

+ + Cherry Studio + + + 北京大学 + + + UCloud 优刻得 + + + 阿里云 + + + IO.NET + +

+ +--- + +## 🙏 特别鸣谢 + +

+ + JetBrains Logo + +

+ +

+ 感谢 JetBrains 为本项目提供免费的开源开发许可证 +

+ +--- + +## 🚀 快速开始 + +### 使用 Docker Compose(推荐) + +```bash +# 克隆项目 +git clone https://github.com/QuantumNous/new-api.git +cd new-api + +# 编辑 docker-compose.yml 配置 +nano docker-compose.yml + +# 启动服务 +docker-compose up -d +``` + +
+使用 Docker 命令 + +```bash +# 拉取最新镜像 +docker pull calciumion/new-api:latest + +# 使用 SQLite(默认) +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest + +# 使用 MySQL +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest +``` + +> **💡 提示:** `-v ./data:/data` 会将数据保存在当前目录的 `data` 文件夹中,你也可以改为绝对路径如 `-v /your/custom/path:/data` + +
+ +--- + +🎉 部署完成后,访问 `http://localhost:3000` 即可使用! + +📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/installation) + +--- + +## 📚 文档 + +
+ +### 📖 [官方文档](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) + +
+ +**快速导航:** + +| 分类 | 链接 | +|------|------| +| 🚀 部署指南 | [安装文档](https://docs.newapi.pro/installation) | +| ⚙️ 环境配置 | [环境变量](https://docs.newapi.pro/installation/environment-variables) | +| 📡 接口文档 | [API 文档](https://docs.newapi.pro/api) | +| ❓ 常见问题 | [FAQ](https://docs.newapi.pro/support/faq) | +| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/support/community-interaction) | + +--- + +## ✨ 主要特性 + +> 详细特性请参考 [特性说明](https://docs.newapi.pro/wiki/features-introduction) + +### 🎨 核心功能 + +| 特性 | 说明 | +|------|------| +| 🎨 全新 UI | 现代化的用户界面设计 | +| 🌍 多语言 | 支持中文、英文、法语、日语 | +| 🔄 数据兼容 | 完全兼容原版 One API 数据库 | +| 📈 数据看板 | 可视化控制台与统计分析 | +| 🔒 权限管理 | 令牌分组、模型限制、用户管理 | + +### 💰 支付与计费 + +- ✅ 在线充值(易支付、Stripe) +- ✅ 模型按次数收费 +- ✅ 缓存计费支持(OpenAI、Azure、DeepSeek、Claude、Qwen等所有支持的模型) +- ✅ 灵活的计费策略配置 + +### 🔐 授权与安全 + +- 😈 Discord 授权登录 +- 🤖 LinuxDO 授权登录 +- 📱 Telegram 授权登录 +- 🔑 OIDC 统一认证 +- 🔍 Key 查询使用额度(配合 [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)) + +### 🚀 高级功能 + +**API 格式支持:** +- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses) +- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)(含 Azure) +- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat) +- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/) +- 🔄 [Rerank 模型](https://docs.newapi.pro/api/jinaai-rerank)(Cohere、Jina) + +**智能路由:** +- ⚖️ 渠道加权随机 +- 🔄 失败自动重试 +- 🚦 用户级别模型限流 + +**格式转换:** +- 🔄 OpenAI ⇄ Claude Messages +- 🔄 OpenAI ⇄ Gemini Chat +- 🔄 思考转内容功能 + +**Reasoning Effort 支持:** + +
+查看详细配置 + +**OpenAI 系列模型:** +- `o3-mini-high` - High reasoning effort +- `o3-mini-medium` - Medium reasoning effort +- `o3-mini-low` - Low reasoning effort +- `gpt-5-high` - High reasoning effort +- `gpt-5-medium` - Medium reasoning effort +- `gpt-5-low` - Low reasoning effort + +**Claude 思考模型:** +- `claude-3-7-sonnet-20250219-thinking` - 启用思考模式 + +**Google Gemini 系列模型:** +- `gemini-2.5-flash-thinking` - 启用思考模式 +- `gemini-2.5-flash-nothinking` - 禁用思考模式 +- `gemini-2.5-pro-thinking` - 启用思考模式 +- `gemini-2.5-pro-thinking-128` - 启用思考模式,并设置思考预算为128tokens + +
+ +--- + +## 🤖 模型支持 + +> 详情请参考 [接口文档 - 中继接口](https://docs.newapi.pro/api) + +| 模型类型 | 说明 | 文档 | +|---------|------|------| +| 🤖 OpenAI GPTs | gpt-4-gizmo-* 系列 | - | +| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文档](https://docs.newapi.pro/api/midjourney-proxy-image) | +| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文档](https://docs.newapi.pro/api/suno-music) | +| 🔄 Rerank | Cohere、Jina | [文档](https://docs.newapi.pro/api/jinaai-rerank) | +| 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/api/anthropic-chat) | +| 🌐 Gemini | Google Gemini 格式 | [文档](https://docs.newapi.pro/api/google-gemini-chat/) | +| 🔧 Dify | ChatFlow 模式 | - | +| 🎯 自定义 | 支持完整调用地址 | - | + +### 📡 支持的接口 + +
+查看完整接口列表 + +- [聊天接口 (Chat Completions)](https://docs.newapi.pro/api/openai-chat) +- [响应接口 (Responses)](https://docs.newapi.pro/api/openai-responses) +- [图像接口 (Image)](https://docs.newapi.pro/api/openai-image) +- [音频接口 (Audio)](https://docs.newapi.pro/api/openai-audio) +- [视频接口 (Video)](https://docs.newapi.pro/api/openai-video) +- [嵌入接口 (Embeddings)](https://docs.newapi.pro/api/openai-embeddings) +- [重排序接口 (Rerank)](https://docs.newapi.pro/api/jinaai-rerank) +- [实时对话 (Realtime)](https://docs.newapi.pro/api/openai-realtime) +- [Claude 聊天](https://docs.newapi.pro/api/anthropic-chat) +- [Google Gemini 聊天](https://docs.newapi.pro/api/google-gemini-chat) + +
+ +--- + +## 🚢 部署 + +> [!TIP] +> **最新版 Docker 镜像:** `calciumion/new-api:latest` + +### 📋 部署要求 + +| 组件 | 要求 | +|------|------| +| **本地数据库** | SQLite(Docker 需挂载 `/data` 目录)| +| **远程数据库** | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6 | +| **容器引擎** | Docker / Docker Compose | + +### ⚙️ 环境变量配置 + +
+常用环境变量配置 + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `SESSION_SECRET` | 会话密钥(多机部署必须) | - | +| `CRYPTO_SECRET` | 加密密钥(Redis 必须) | - | +| `SQL_DSN` | 数据库连接字符串 | - | +| `REDIS_CONN_STRING` | Redis 连接字符串 | - | +| `STREAMING_TIMEOUT` | 流式超时时间(秒) | `300` | +| `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` | +| `ERROR_LOG_ENABLED` | 错误日志开关 | `false` | + +📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/installation/environment-variables) + +
+ +### 🔧 部署方式 + +
+方式 1:Docker Compose(推荐) + +```bash +# 克隆项目 +git clone https://github.com/QuantumNous/new-api.git +cd new-api + +# 编辑配置 +nano docker-compose.yml + +# 启动服务 +docker-compose up -d +``` + +
+ +
+方式 2:Docker 命令 + +**使用 SQLite:** +```bash +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest +``` + +**使用 MySQL:** +```bash +docker run --name new-api -d --restart always \ + -p 3000:3000 \ + -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \ + -e TZ=Asia/Shanghai \ + -v ./data:/data \ + calciumion/new-api:latest +``` + +> **💡 路径说明:** +> - `./data:/data` - 相对路径,数据保存在当前目录的 data 文件夹 +> - 也可使用绝对路径,如:`/your/custom/path:/data` + +
+ +
+方式 3:宝塔面板 + +1. 安装宝塔面板(≥ 9.2.0 版本) +2. 在应用商店搜索 **New-API** +3. 一键安装 + +📖 [图文教程](./docs/BT.md) + +
+ +### ⚠️ 多机部署注意事项 + +> [!WARNING] +> - **必须设置** `SESSION_SECRET` - 否则登录状态不一致 +> - **公用 Redis 必须设置** `CRYPTO_SECRET` - 否则数据无法解密 + +### 🔄 渠道重试与缓存 + +**重试配置:** `设置 → 运营设置 → 通用设置 → 失败重试次数` + +**缓存配置:** +- `REDIS_CONN_STRING`:Redis 缓存(推荐) +- `MEMORY_CACHE_ENABLED`:内存缓存 + +--- + +## 🔗 相关项目 + +### 上游项目 + +| 项目 | 说明 | +|------|------| +| [One API](https://github.com/songquanpeng/one-api) | 原版项目基础 | +| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourney 接口支持 | + +### 配套工具 + +| 项目 | 说明 | +|------|------| +| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key 额度查询工具 | +| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API 高性能优化版 | + +--- + +## 💬 帮助支持 + +### 📖 文档资源 + +| 资源 | 链接 | +|------|------| +| 📘 常见问题 | [FAQ](https://docs.newapi.pro/support/faq) | +| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/support/community-interaction) | +| 🐛 反馈问题 | [问题反馈](https://docs.newapi.pro/support/feedback-issues) | +| 📚 完整文档 | [官方文档](https://docs.newapi.pro/support) | + +### 🤝 贡献指南 + +欢迎各种形式的贡献! + +- 🐛 报告 Bug +- 💡 提出新功能 +- 📝 改进文档 +- 🔧 提交代码 + +--- + +## 🌟 Star History + +
+ +[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date) + +
+ +--- + +
+ +### 💖 感谢使用 New API + +如果这个项目对你有帮助,欢迎给我们一个 ⭐️ Star! + +**[官方文档](https://docs.newapi.pro/)** • **[问题反馈](https://github.com/Calcium-Ion/new-api/issues)** • **[最新发布](https://github.com/Calcium-Ion/new-api/releases)** + +Built with ❤️ by QuantumNous + +
diff --git a/VERSION b/VERSION new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/bin/migration_v0.2-v0.3.sql b/bin/migration_v0.2-v0.3.sql new file mode 100644 index 0000000000000000000000000000000000000000..6b08d7bf9f4aa70e6ceeaa56137cd1bc8db075f5 --- /dev/null +++ b/bin/migration_v0.2-v0.3.sql @@ -0,0 +1,6 @@ +UPDATE users +SET quota = quota + ( + SELECT SUM(remain_quota) + FROM tokens + WHERE tokens.user_id = users.id +) diff --git a/bin/migration_v0.3-v0.4.sql b/bin/migration_v0.3-v0.4.sql new file mode 100644 index 0000000000000000000000000000000000000000..e6103c29acff677acf5d88f5df380d076e5e129f --- /dev/null +++ b/bin/migration_v0.3-v0.4.sql @@ -0,0 +1,17 @@ +INSERT INTO abilities (`group`, model, channel_id, enabled) +SELECT c.`group`, m.model, c.id, 1 +FROM channels c +CROSS JOIN ( + SELECT 'gpt-3.5-turbo' AS model UNION ALL + SELECT 'gpt-3.5-turbo-0301' AS model UNION ALL + SELECT 'gpt-4' AS model UNION ALL + SELECT 'gpt-4-0314' AS model +) AS m +WHERE c.status = 1 + AND NOT EXISTS ( + SELECT 1 + FROM abilities a + WHERE a.`group` = c.`group` + AND a.model = m.model + AND a.channel_id = c.id +); diff --git a/bin/time_test.sh b/bin/time_test.sh new file mode 100644 index 0000000000000000000000000000000000000000..2cde4a65bbd4b3d7e60ca55b504d78b02b999f9d --- /dev/null +++ b/bin/time_test.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +if [ $# -lt 3 ]; then + echo "Usage: time_test.sh []" + exit 1 +fi + +domain=$1 +key=$2 +count=$3 +model=${4:-"gpt-3.5-turbo"} # 设置默认模型为 gpt-3.5-turbo + +total_time=0 +times=() + +for ((i=1; i<=count; i++)); do + result=$(curl -o /dev/null -s -w "%{http_code} %{time_total}\\n" \ + https://"$domain"/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $key" \ + -d '{"messages": [{"content": "echo hi", "role": "user"}], "model": "'"$model"'", "stream": false, "max_tokens": 1}') + http_code=$(echo "$result" | awk '{print $1}') + time=$(echo "$result" | awk '{print $2}') + echo "HTTP status code: $http_code, Time taken: $time" + total_time=$(bc <<< "$total_time + $time") + times+=("$time") +done + +average_time=$(echo "scale=4; $total_time / $count" | bc) + +sum_of_squares=0 +for time in "${times[@]}"; do + difference=$(echo "scale=4; $time - $average_time" | bc) + square=$(echo "scale=4; $difference * $difference" | bc) + sum_of_squares=$(echo "scale=4; $sum_of_squares + $square" | bc) +done + +standard_deviation=$(echo "scale=4; sqrt($sum_of_squares / $count)" | bc) + +echo "Average time: $average_time±$standard_deviation" diff --git a/common/api_type.go b/common/api_type.go new file mode 100644 index 0000000000000000000000000000000000000000..4f5c1826a573fb7d5dea8048caa73150e880b9b4 --- /dev/null +++ b/common/api_type.go @@ -0,0 +1,81 @@ +package common + +import "github.com/QuantumNous/new-api/constant" + +func ChannelType2APIType(channelType int) (int, bool) { + apiType := -1 + switch channelType { + case constant.ChannelTypeOpenAI: + apiType = constant.APITypeOpenAI + case constant.ChannelTypeAnthropic: + apiType = constant.APITypeAnthropic + case constant.ChannelTypeBaidu: + apiType = constant.APITypeBaidu + case constant.ChannelTypePaLM: + apiType = constant.APITypePaLM + case constant.ChannelTypeZhipu: + apiType = constant.APITypeZhipu + case constant.ChannelTypeAli: + apiType = constant.APITypeAli + case constant.ChannelTypeXunfei: + apiType = constant.APITypeXunfei + case constant.ChannelTypeAIProxyLibrary: + apiType = constant.APITypeAIProxyLibrary + case constant.ChannelTypeTencent: + apiType = constant.APITypeTencent + case constant.ChannelTypeGemini: + apiType = constant.APITypeGemini + case constant.ChannelTypeZhipu_v4: + apiType = constant.APITypeZhipuV4 + case constant.ChannelTypeOllama: + apiType = constant.APITypeOllama + case constant.ChannelTypePerplexity: + apiType = constant.APITypePerplexity + case constant.ChannelTypeAws: + apiType = constant.APITypeAws + case constant.ChannelTypeCohere: + apiType = constant.APITypeCohere + case constant.ChannelTypeDify: + apiType = constant.APITypeDify + case constant.ChannelTypeJina: + apiType = constant.APITypeJina + case constant.ChannelCloudflare: + apiType = constant.APITypeCloudflare + case constant.ChannelTypeSiliconFlow: + apiType = constant.APITypeSiliconFlow + case constant.ChannelTypeVertexAi: + apiType = constant.APITypeVertexAi + case constant.ChannelTypeMistral: + apiType = constant.APITypeMistral + case constant.ChannelTypeDeepSeek: + apiType = constant.APITypeDeepSeek + case constant.ChannelTypeMokaAI: + apiType = constant.APITypeMokaAI + case constant.ChannelTypeVolcEngine: + apiType = constant.APITypeVolcEngine + case constant.ChannelTypeBaiduV2: + apiType = constant.APITypeBaiduV2 + case constant.ChannelTypeOpenRouter: + apiType = constant.APITypeOpenRouter + case constant.ChannelTypeXinference: + apiType = constant.APITypeXinference + case constant.ChannelTypeXai: + apiType = constant.APITypeXai + case constant.ChannelTypeCoze: + apiType = constant.APITypeCoze + case constant.ChannelTypeJimeng: + apiType = constant.APITypeJimeng + case constant.ChannelTypeMoonshot: + apiType = constant.APITypeMoonshot + case constant.ChannelTypeSubmodel: + apiType = constant.APITypeSubmodel + case constant.ChannelTypeMiniMax: + apiType = constant.APITypeMiniMax + case constant.ChannelTypeReplicate: + apiType = constant.APITypeReplicate + } + if apiType == -1 { + return constant.APITypeOpenAI, false + } + return apiType, true +} diff --git a/common/audio.go b/common/audio.go new file mode 100644 index 0000000000000000000000000000000000000000..1eb1079de0cc5a6faa28b4d56cda7151520e3ade --- /dev/null +++ b/common/audio.go @@ -0,0 +1,296 @@ +package common + +import ( + "context" + "encoding/binary" + "fmt" + "io" + + "github.com/abema/go-mp4" + "github.com/go-audio/aiff" + "github.com/go-audio/wav" + "github.com/jfreymuth/oggvorbis" + "github.com/mewkiz/flac" + "github.com/pkg/errors" + "github.com/tcolgate/mp3" + "github.com/yapingcat/gomedia/go-codec" +) + +// GetAudioDuration 使用纯 Go 库获取音频文件的时长(秒)。 +// 它不再依赖外部的 ffmpeg 或 ffprobe 程序。 +func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) { + SysLog(fmt.Sprintf("GetAudioDuration: ext=%s", ext)) + // 根据文件扩展名选择解析器 + switch ext { + case ".mp3": + duration, err = getMP3Duration(f) + case ".wav": + duration, err = getWAVDuration(f) + case ".flac": + duration, err = getFLACDuration(f) + case ".m4a", ".mp4": + duration, err = getM4ADuration(f) + case ".ogg", ".oga", ".opus": + duration, err = getOGGDuration(f) + if err != nil { + duration, err = getOpusDuration(f) + } + case ".aiff", ".aif", ".aifc": + duration, err = getAIFFDuration(f) + case ".webm": + duration, err = getWebMDuration(f) + case ".aac": + duration, err = getAACDuration(f) + default: + return 0, fmt.Errorf("unsupported audio format: %s", ext) + } + SysLog(fmt.Sprintf("GetAudioDuration: duration=%f", duration)) + return duration, err +} + +// getMP3Duration 解析 MP3 文件以获取时长。 +// 注意:对于 VBR (Variable Bitrate) MP3,这个估算可能不完全精确,但通常足够好。 +// FFmpeg 在这种情况下会扫描整个文件来获得精确值,但这里的库提供了快速估算。 +func getMP3Duration(r io.Reader) (float64, error) { + d := mp3.NewDecoder(r) + var f mp3.Frame + skipped := 0 + duration := 0.0 + + for { + if err := d.Decode(&f, &skipped); err != nil { + if err == io.EOF { + break + } + return 0, errors.Wrap(err, "failed to decode mp3 frame") + } + duration += f.Duration().Seconds() + } + return duration, nil +} + +// getWAVDuration 解析 WAV 文件头以获取时长。 +func getWAVDuration(r io.ReadSeeker) (float64, error) { + dec := wav.NewDecoder(r) + if !dec.IsValidFile() { + return 0, errors.New("invalid wav file") + } + d, err := dec.Duration() + if err != nil { + return 0, errors.Wrap(err, "failed to get wav duration") + } + return d.Seconds(), nil +} + +// getFLACDuration 解析 FLAC 文件的 STREAMINFO 块。 +func getFLACDuration(r io.Reader) (float64, error) { + stream, err := flac.Parse(r) + if err != nil { + return 0, errors.Wrap(err, "failed to parse flac stream") + } + defer stream.Close() + + // 时长 = 总采样数 / 采样率 + duration := float64(stream.Info.NSamples) / float64(stream.Info.SampleRate) + return duration, nil +} + +// getM4ADuration 解析 M4A/MP4 文件的 'mvhd' box。 +func getM4ADuration(r io.ReadSeeker) (float64, error) { + // go-mp4 库需要 ReadSeeker 接口 + info, err := mp4.Probe(r) + if err != nil { + return 0, errors.Wrap(err, "failed to probe m4a/mp4 file") + } + // 时长 = Duration / Timescale + return float64(info.Duration) / float64(info.Timescale), nil +} + +// getOGGDuration 解析 OGG/Vorbis 文件以获取时长。 +func getOGGDuration(r io.ReadSeeker) (float64, error) { + // 重置 reader 到开头 + if _, err := r.Seek(0, io.SeekStart); err != nil { + return 0, errors.Wrap(err, "failed to seek ogg file") + } + + reader, err := oggvorbis.NewReader(r) + if err != nil { + return 0, errors.Wrap(err, "failed to create ogg vorbis reader") + } + + // 计算时长 = 总采样数 / 采样率 + // 需要读取整个文件来获取总采样数 + channels := reader.Channels() + sampleRate := reader.SampleRate() + + // 估算方法:读取到文件结尾 + var totalSamples int64 + buf := make([]float32, 4096*channels) + for { + n, err := reader.Read(buf) + if err == io.EOF { + break + } + if err != nil { + return 0, errors.Wrap(err, "failed to read ogg samples") + } + totalSamples += int64(n / channels) + } + + duration := float64(totalSamples) / float64(sampleRate) + return duration, nil +} + +// getOpusDuration 解析 Opus 文件(在 OGG 容器中)以获取时长。 +func getOpusDuration(r io.ReadSeeker) (float64, error) { + // Opus 通常封装在 OGG 容器中 + // 我们需要解析 OGG 页面来获取时长信息 + if _, err := r.Seek(0, io.SeekStart); err != nil { + return 0, errors.Wrap(err, "failed to seek opus file") + } + + // 读取 OGG 页面头部 + var totalGranulePos int64 + buf := make([]byte, 27) // OGG 页面头部最小大小 + + for { + n, err := r.Read(buf) + if err == io.EOF { + break + } + if err != nil { + return 0, errors.Wrap(err, "failed to read opus/ogg page") + } + if n < 27 { + break + } + + // 检查 OGG 页面标识 "OggS" + if string(buf[0:4]) != "OggS" { + // 跳过一些字节继续寻找 + if _, err := r.Seek(-26, io.SeekCurrent); err != nil { + break + } + continue + } + + // 读取 granule position (字节 6-13, 小端序) + granulePos := int64(binary.LittleEndian.Uint64(buf[6:14])) + if granulePos > totalGranulePos { + totalGranulePos = granulePos + } + + // 读取段表大小 + numSegments := int(buf[26]) + segmentTable := make([]byte, numSegments) + if _, err := io.ReadFull(r, segmentTable); err != nil { + break + } + + // 计算页面数据大小并跳过 + var pageSize int + for _, segSize := range segmentTable { + pageSize += int(segSize) + } + if _, err := r.Seek(int64(pageSize), io.SeekCurrent); err != nil { + break + } + } + + // Opus 的采样率固定为 48000 Hz + duration := float64(totalGranulePos) / 48000.0 + return duration, nil +} + +// getAIFFDuration 解析 AIFF 文件头以获取时长。 +func getAIFFDuration(r io.ReadSeeker) (float64, error) { + if _, err := r.Seek(0, io.SeekStart); err != nil { + return 0, errors.Wrap(err, "failed to seek aiff file") + } + + dec := aiff.NewDecoder(r) + if !dec.IsValidFile() { + return 0, errors.New("invalid aiff file") + } + + d, err := dec.Duration() + if err != nil { + return 0, errors.Wrap(err, "failed to get aiff duration") + } + + return d.Seconds(), nil +} + +// getWebMDuration 解析 WebM 文件以获取时长。 +// WebM 使用 Matroska 容器格式 +func getWebMDuration(r io.ReadSeeker) (float64, error) { + if _, err := r.Seek(0, io.SeekStart); err != nil { + return 0, errors.Wrap(err, "failed to seek webm file") + } + + // WebM/Matroska 文件的解析比较复杂 + // 这里提供一个简化的实现,读取 EBML 头部 + // 对于完整的 WebM 解析,可能需要使用专门的库 + + // 简单实现:查找 Duration 元素 + // WebM Duration 的 Element ID 是 0x4489 + // 这是一个简化版本,可能不适用于所有 WebM 文件 + buf := make([]byte, 8192) + n, err := r.Read(buf) + if err != nil && err != io.EOF { + return 0, errors.Wrap(err, "failed to read webm file") + } + + // 尝试查找 Duration 元素(这是一个简化的方法) + // 实际的 WebM 解析需要完整的 EBML 解析器 + // 这里返回错误,建议使用专门的库 + if n > 0 { + // 检查 EBML 标识 + if len(buf) >= 4 && binary.BigEndian.Uint32(buf[0:4]) == 0x1A45DFA3 { + // 这是一个有效的 EBML 文件 + // 但完整解析需要更复杂的逻辑 + return 0, errors.New("webm duration parsing requires full EBML parser (consider using ffprobe for webm files)") + } + } + + return 0, errors.New("failed to parse webm file") +} + +// getAACDuration 解析 AAC (ADTS格式) 文件以获取时长。 +// 使用 gomedia 库来解析 AAC ADTS 帧 +func getAACDuration(r io.ReadSeeker) (float64, error) { + if _, err := r.Seek(0, io.SeekStart); err != nil { + return 0, errors.Wrap(err, "failed to seek aac file") + } + + // 读取整个文件内容 + data, err := io.ReadAll(r) + if err != nil { + return 0, errors.Wrap(err, "failed to read aac file") + } + + var totalFrames int64 + var sampleRate int + + // 使用 gomedia 的 SplitAACFrame 函数来分割 AAC 帧 + codec.SplitAACFrame(data, func(aac []byte) { + // 解析 ADTS 头部以获取采样率信息 + if len(aac) >= 7 { + // 使用 ConvertADTSToASC 来获取音频配置信息 + asc, err := codec.ConvertADTSToASC(aac) + if err == nil && sampleRate == 0 { + sampleRate = codec.AACSampleIdxToSample(int(asc.Sample_freq_index)) + } + totalFrames++ + } + }) + + if sampleRate == 0 || totalFrames == 0 { + return 0, errors.New("no valid aac frames found") + } + + // 每个 AAC ADTS 帧包含 1024 个采样 + totalSamples := totalFrames * 1024 + duration := float64(totalSamples) / float64(sampleRate) + return duration, nil +} diff --git a/common/constants.go b/common/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..120c1e9d24d208c8778f4de4a11a483381bbef39 --- /dev/null +++ b/common/constants.go @@ -0,0 +1,204 @@ +package common + +import ( + //"os" + //"strconv" + "sync" + "time" + + "github.com/google/uuid" +) + +var StartTime = time.Now().Unix() // unit: second +var Version = "v0.0.0" // this hard coding will be replaced automatically when building, no need to manually change +var SystemName = "New API" +var Footer = "" +var Logo = "" +var TopUpLink = "" + +// var ChatLink = "" +// var ChatLink2 = "" +var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens +// 保留旧变量以兼容历史逻辑,实际展示由 general_setting.quota_display_type 控制 +var DisplayInCurrencyEnabled = true +var DisplayTokenStatEnabled = true +var DrawingEnabled = true +var TaskEnabled = true +var DataExportEnabled = true +var DataExportInterval = 5 // unit: minute +var DataExportDefaultTime = "hour" // unit: minute +var DefaultCollapseSidebar = false // default value of collapse sidebar + +// Any options with "Secret", "Token" in its key won't be return by GetOptions + +var SessionSecret = uuid.New().String() +var CryptoSecret = uuid.New().String() + +var OptionMap map[string]string +var OptionMapRWMutex sync.RWMutex + +var ItemsPerPage = 10 +var MaxRecentItems = 100 + +var PasswordLoginEnabled = true +var PasswordRegisterEnabled = true +var EmailVerificationEnabled = false +var GitHubOAuthEnabled = false +var LinuxDOOAuthEnabled = false +var WeChatAuthEnabled = false +var TelegramOAuthEnabled = false +var TurnstileCheckEnabled = false +var RegisterEnabled = true + +var EmailDomainRestrictionEnabled = false // 是否启用邮箱域名限制 +var EmailAliasRestrictionEnabled = false // 是否启用邮箱别名限制 +var EmailDomainWhitelist = []string{ + "gmail.com", + "163.com", + "126.com", + "qq.com", + "outlook.com", + "hotmail.com", + "icloud.com", + "yahoo.com", + "foxmail.com", +} +var EmailLoginAuthServerList = []string{ + "smtp.sendcloud.net", + "smtp.azurecomm.net", +} + +var DebugEnabled bool +var MemoryCacheEnabled bool + +var LogConsumeEnabled = true + +var SMTPServer = "" +var SMTPPort = 587 +var SMTPSSLEnabled = false +var SMTPAccount = "" +var SMTPFrom = "" +var SMTPToken = "" + +var GitHubClientId = "" +var GitHubClientSecret = "" +var LinuxDOClientId = "" +var LinuxDOClientSecret = "" +var LinuxDOMinimumTrustLevel = 0 + +var WeChatServerAddress = "" +var WeChatServerToken = "" +var WeChatAccountQRCodeImageURL = "" + +var TurnstileSiteKey = "" +var TurnstileSecretKey = "" + +var TelegramBotToken = "" +var TelegramBotName = "" + +var QuotaForNewUser = 0 +var QuotaForInviter = 0 +var QuotaForInvitee = 0 +var ChannelDisableThreshold = 5.0 +var AutomaticDisableChannelEnabled = false +var AutomaticEnableChannelEnabled = false +var QuotaRemindThreshold = 1000 +var PreConsumedQuota = 500 + +var RetryTimes = 0 + +//var RootUserEmail = "" + +var IsMasterNode bool + +var requestInterval int +var RequestInterval time.Duration + +var SyncFrequency int // unit is second + +var BatchUpdateEnabled = false +var BatchUpdateInterval int + +var RelayTimeout int // unit is second + +var GeminiSafetySetting string + +// https://docs.cohere.com/docs/safety-modes Type; NONE/CONTEXTUAL/STRICT +var CohereSafetySetting string + +const ( + RequestIdKey = "X-Oneapi-Request-Id" +) + +const ( + RoleGuestUser = 0 + RoleCommonUser = 1 + RoleAdminUser = 10 + RoleRootUser = 100 +) + +func IsValidateRole(role int) bool { + return role == RoleGuestUser || role == RoleCommonUser || role == RoleAdminUser || role == RoleRootUser +} + +var ( + FileUploadPermission = RoleGuestUser + FileDownloadPermission = RoleGuestUser + ImageUploadPermission = RoleGuestUser + ImageDownloadPermission = RoleGuestUser +) + +// All duration's unit is seconds +// Shouldn't larger then RateLimitKeyExpirationDuration +var ( + GlobalApiRateLimitEnable bool + GlobalApiRateLimitNum int + GlobalApiRateLimitDuration int64 + + GlobalWebRateLimitEnable bool + GlobalWebRateLimitNum int + GlobalWebRateLimitDuration int64 + + CriticalRateLimitEnable bool + CriticalRateLimitNum = 20 + CriticalRateLimitDuration int64 = 20 * 60 + + UploadRateLimitNum = 10 + UploadRateLimitDuration int64 = 60 + + DownloadRateLimitNum = 10 + DownloadRateLimitDuration int64 = 60 +) + +var RateLimitKeyExpirationDuration = 20 * time.Minute + +const ( + UserStatusEnabled = 1 // don't use 0, 0 is the default value! + UserStatusDisabled = 2 // also don't use 0 +) + +const ( + TokenStatusEnabled = 1 // don't use 0, 0 is the default value! + TokenStatusDisabled = 2 // also don't use 0 + TokenStatusExpired = 3 + TokenStatusExhausted = 4 +) + +const ( + RedemptionCodeStatusEnabled = 1 // don't use 0, 0 is the default value! + RedemptionCodeStatusDisabled = 2 // also don't use 0 + RedemptionCodeStatusUsed = 3 // also don't use 0 +) + +const ( + ChannelStatusUnknown = 0 + ChannelStatusEnabled = 1 // don't use 0, 0 is the default value! + ChannelStatusManuallyDisabled = 2 // also don't use 0 + ChannelStatusAutoDisabled = 3 +) + +const ( + TopUpStatusPending = "pending" + TopUpStatusSuccess = "success" + TopUpStatusExpired = "expired" +) diff --git a/common/copy.go b/common/copy.go new file mode 100644 index 0000000000000000000000000000000000000000..3edb2fa2537e385e16802cfceeb4d93923fba9c6 --- /dev/null +++ b/common/copy.go @@ -0,0 +1,19 @@ +package common + +import ( + "fmt" + + "github.com/jinzhu/copier" +) + +func DeepCopy[T any](src *T) (*T, error) { + if src == nil { + return nil, fmt.Errorf("copy source cannot be nil") + } + var dst T + err := copier.CopyWithOption(&dst, src, copier.Option{DeepCopy: true, IgnoreEmpty: true}) + if err != nil { + return nil, err + } + return &dst, nil +} diff --git a/common/crypto.go b/common/crypto.go new file mode 100644 index 0000000000000000000000000000000000000000..3ca06bd2d6c3883d12e9f2a45825c6921544c548 --- /dev/null +++ b/common/crypto.go @@ -0,0 +1,32 @@ +package common + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + + "golang.org/x/crypto/bcrypt" +) + +func GenerateHMACWithKey(key []byte, data string) string { + h := hmac.New(sha256.New, key) + h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} + +func GenerateHMAC(data string) string { + h := hmac.New(sha256.New, []byte(CryptoSecret)) + h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} + +func Password2Hash(password string) (string, error) { + passwordBytes := []byte(password) + hashedPassword, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost) + return string(hashedPassword), err +} + +func ValidatePasswordAndHash(password string, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/common/custom-event.go b/common/custom-event.go new file mode 100644 index 0000000000000000000000000000000000000000..256db546931b3c6233ab4341e729fb59f8025afa --- /dev/null +++ b/common/custom-event.go @@ -0,0 +1,87 @@ +// Copyright 2014 Manu Martinez-Almeida. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package common + +import ( + "fmt" + "io" + "net/http" + "strings" + "sync" +) + +type stringWriter interface { + io.Writer + writeString(string) (int, error) +} + +type stringWrapper struct { + io.Writer +} + +func (w stringWrapper) writeString(str string) (int, error) { + return w.Writer.Write([]byte(str)) +} + +func checkWriter(writer io.Writer) stringWriter { + if w, ok := writer.(stringWriter); ok { + return w + } else { + return stringWrapper{writer} + } +} + +// Server-Sent Events +// W3C Working Draft 29 October 2009 +// http://www.w3.org/TR/2009/WD-eventsource-20091029/ + +var contentType = []string{"text/event-stream"} +var noCache = []string{"no-cache"} + +var fieldReplacer = strings.NewReplacer( + "\n", "\\n", + "\r", "\\r") + +var dataReplacer = strings.NewReplacer( + "\n", "\n", + "\r", "\\r") + +type CustomEvent struct { + Event string + Id string + Retry uint + Data interface{} + + Mutex sync.Mutex +} + +func encode(writer io.Writer, event CustomEvent) error { + w := checkWriter(writer) + return writeData(w, event.Data) +} + +func writeData(w stringWriter, data interface{}) error { + dataReplacer.WriteString(w, fmt.Sprint(data)) + if strings.HasPrefix(data.(string), "data") { + w.writeString("\n\n") + } + return nil +} + +func (r CustomEvent) Render(w http.ResponseWriter) error { + r.WriteContentType(w) + return encode(w, r) +} + +func (r CustomEvent) WriteContentType(w http.ResponseWriter) { + r.Mutex.Lock() + defer r.Mutex.Unlock() + header := w.Header() + header["Content-Type"] = contentType + + if _, exist := header["Cache-Control"]; !exist { + header["Cache-Control"] = noCache + } +} diff --git a/common/database.go b/common/database.go new file mode 100644 index 0000000000000000000000000000000000000000..71dbd94d588c94ebc03f643816ab98486d280e53 --- /dev/null +++ b/common/database.go @@ -0,0 +1,15 @@ +package common + +const ( + DatabaseTypeMySQL = "mysql" + DatabaseTypeSQLite = "sqlite" + DatabaseTypePostgreSQL = "postgres" +) + +var UsingSQLite = false +var UsingPostgreSQL = false +var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries +var UsingMySQL = false +var UsingClickHouse = false + +var SQLitePath = "one-api.db?_busy_timeout=30000" diff --git a/common/email-outlook-auth.go b/common/email-outlook-auth.go new file mode 100644 index 0000000000000000000000000000000000000000..f6a71b8e818dd95b80874087169fbbcceb3939de --- /dev/null +++ b/common/email-outlook-auth.go @@ -0,0 +1,40 @@ +package common + +import ( + "errors" + "net/smtp" + "strings" +) + +type outlookAuth struct { + username, password string +} + +func LoginAuth(username, password string) smtp.Auth { + return &outlookAuth{username, password} +} + +func (a *outlookAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", []byte{}, nil +} + +func (a *outlookAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch string(fromServer) { + case "Username:": + return []byte(a.username), nil + case "Password:": + return []byte(a.password), nil + default: + return nil, errors.New("unknown fromServer") + } + } + return nil, nil +} + +func isOutlookServer(server string) bool { + // 兼容多地区的outlook邮箱和ofb邮箱 + // 其实应该加一个Option来区分是否用LOGIN的方式登录 + // 先临时兼容一下 + return strings.Contains(server, "outlook") || strings.Contains(server, "onmicrosoft") +} diff --git a/common/email.go b/common/email.go new file mode 100644 index 0000000000000000000000000000000000000000..e27d8bcd17b10b71940caece02448138c67a120f --- /dev/null +++ b/common/email.go @@ -0,0 +1,93 @@ +package common + +import ( + "crypto/tls" + "encoding/base64" + "fmt" + "net/smtp" + "slices" + "strings" + "time" +) + +func generateMessageID() (string, error) { + split := strings.Split(SMTPFrom, "@") + if len(split) < 2 { + return "", fmt.Errorf("invalid SMTP account") + } + domain := strings.Split(SMTPFrom, "@")[1] + return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil +} + +func SendEmail(subject string, receiver string, content string) error { + if SMTPFrom == "" { // for compatibility + SMTPFrom = SMTPAccount + } + id, err2 := generateMessageID() + if err2 != nil { + return err2 + } + if SMTPServer == "" && SMTPAccount == "" { + return fmt.Errorf("SMTP 服务器未配置") + } + encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject))) + mail := []byte(fmt.Sprintf("To: %s\r\n"+ + "From: %s<%s>\r\n"+ + "Subject: %s\r\n"+ + "Date: %s\r\n"+ + "Message-ID: %s\r\n"+ // 添加 Message-ID 头 + "Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n", + receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content)) + auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer) + addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort) + to := strings.Split(receiver, ";") + var err error + if SMTPPort == 465 || SMTPSSLEnabled { + tlsConfig := &tls.Config{ + InsecureSkipVerify: true, + ServerName: SMTPServer, + } + conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", SMTPServer, SMTPPort), tlsConfig) + if err != nil { + return err + } + client, err := smtp.NewClient(conn, SMTPServer) + if err != nil { + return err + } + defer client.Close() + if err = client.Auth(auth); err != nil { + return err + } + if err = client.Mail(SMTPFrom); err != nil { + return err + } + receiverEmails := strings.Split(receiver, ";") + for _, receiver := range receiverEmails { + if err = client.Rcpt(receiver); err != nil { + return err + } + } + w, err := client.Data() + if err != nil { + return err + } + _, err = w.Write(mail) + if err != nil { + return err + } + err = w.Close() + if err != nil { + return err + } + } else if isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer) { + auth = LoginAuth(SMTPAccount, SMTPToken) + err = smtp.SendMail(addr, auth, SMTPFrom, to, mail) + } else { + err = smtp.SendMail(addr, auth, SMTPFrom, to, mail) + } + if err != nil { + SysError(fmt.Sprintf("failed to send email to %s: %v", receiver, err)) + } + return err +} diff --git a/common/embed-file-system.go b/common/embed-file-system.go new file mode 100644 index 0000000000000000000000000000000000000000..8de8699506a0f401603918072aa3615329d14b3f --- /dev/null +++ b/common/embed-file-system.go @@ -0,0 +1,43 @@ +package common + +import ( + "embed" + "io/fs" + "net/http" + "os" + + "github.com/gin-contrib/static" +) + +// Credit: https://github.com/gin-contrib/static/issues/19 + +type embedFileSystem struct { + http.FileSystem +} + +func (e *embedFileSystem) Exists(prefix string, path string) bool { + _, err := e.Open(path) + if err != nil { + return false + } + return true +} + +func (e *embedFileSystem) Open(name string) (http.File, error) { + if name == "/" { + // This will make sure the index page goes to NoRouter handler, + // which will use the replaced index bytes with analytic codes. + return nil, os.ErrNotExist + } + return e.FileSystem.Open(name) +} + +func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem { + efs, err := fs.Sub(fsEmbed, targetPath) + if err != nil { + panic(err) + } + return &embedFileSystem{ + FileSystem: http.FS(efs), + } +} diff --git a/common/endpoint_defaults.go b/common/endpoint_defaults.go new file mode 100644 index 0000000000000000000000000000000000000000..c04c5f6d8e0e09d2dad8080f9cd498fc0609347d --- /dev/null +++ b/common/endpoint_defaults.go @@ -0,0 +1,33 @@ +package common + +import "github.com/QuantumNous/new-api/constant" + +// EndpointInfo 描述单个端点的默认请求信息 +// path: 上游路径 +// method: HTTP 请求方式,例如 POST/GET +// 目前均为 POST,后续可扩展 +// +// json 标签用于直接序列化到 API 输出 +// 例如:{"path":"/v1/chat/completions","method":"POST"} + +type EndpointInfo struct { + Path string `json:"path"` + Method string `json:"method"` +} + +// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method +var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{ + constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"}, + constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"}, + constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"}, + constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"}, + constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"}, + constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"}, + constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"}, +} + +// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在 +func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) { + info, ok := defaultEndpointInfoMap[et] + return info, ok +} diff --git a/common/endpoint_type.go b/common/endpoint_type.go new file mode 100644 index 0000000000000000000000000000000000000000..65e80ce2f7e17f2f63a3e18d194d684c482b139f --- /dev/null +++ b/common/endpoint_type.go @@ -0,0 +1,43 @@ +package common + +import "github.com/QuantumNous/new-api/constant" + +// GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点) +func GetEndpointTypesByChannelType(channelType int, modelName string) []constant.EndpointType { + var endpointTypes []constant.EndpointType + switch channelType { + case constant.ChannelTypeJina: + endpointTypes = []constant.EndpointType{constant.EndpointTypeJinaRerank} + //case constant.ChannelTypeMidjourney, constant.ChannelTypeMidjourneyPlus: + // endpointTypes = []constant.EndpointType{constant.EndpointTypeMidjourney} + //case constant.ChannelTypeSunoAPI: + // endpointTypes = []constant.EndpointType{constant.EndpointTypeSuno} + //case constant.ChannelTypeKling: + // endpointTypes = []constant.EndpointType{constant.EndpointTypeKling} + //case constant.ChannelTypeJimeng: + // endpointTypes = []constant.EndpointType{constant.EndpointTypeJimeng} + case constant.ChannelTypeAws: + fallthrough + case constant.ChannelTypeAnthropic: + endpointTypes = []constant.EndpointType{constant.EndpointTypeAnthropic, constant.EndpointTypeOpenAI} + case constant.ChannelTypeVertexAi: + fallthrough + case constant.ChannelTypeGemini: + endpointTypes = []constant.EndpointType{constant.EndpointTypeGemini, constant.EndpointTypeOpenAI} + case constant.ChannelTypeOpenRouter: // OpenRouter 只支持 OpenAI 端点 + endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI} + case constant.ChannelTypeSora: + endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo} + default: + if IsOpenAIResponseOnlyModel(modelName) { + endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse} + } else { + endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI} + } + } + if IsImageGenerationModel(modelName) { + // add to first + endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageGeneration}, endpointTypes...) + } + return endpointTypes +} diff --git a/common/env.go b/common/env.go new file mode 100644 index 0000000000000000000000000000000000000000..1aa340f85ea11934d3a5f4e3e7e7c65495bcb157 --- /dev/null +++ b/common/env.go @@ -0,0 +1,38 @@ +package common + +import ( + "fmt" + "os" + "strconv" +) + +func GetEnvOrDefault(env string, defaultValue int) int { + if env == "" || os.Getenv(env) == "" { + return defaultValue + } + num, err := strconv.Atoi(os.Getenv(env)) + if err != nil { + SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %d", env, err.Error(), defaultValue)) + return defaultValue + } + return num +} + +func GetEnvOrDefaultString(env string, defaultValue string) string { + if env == "" || os.Getenv(env) == "" { + return defaultValue + } + return os.Getenv(env) +} + +func GetEnvOrDefaultBool(env string, defaultValue bool) bool { + if env == "" || os.Getenv(env) == "" { + return defaultValue + } + b, err := strconv.ParseBool(os.Getenv(env)) + if err != nil { + SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %t", env, err.Error(), defaultValue)) + return defaultValue + } + return b +} diff --git a/common/gin.go b/common/gin.go new file mode 100644 index 0000000000000000000000000000000000000000..db299f2938f8a9a4e5276cbbb2df41c52b60de62 --- /dev/null +++ b/common/gin.go @@ -0,0 +1,234 @@ +package common + +import ( + "bytes" + "errors" + "io" + "mime" + "mime/multipart" + "net/http" + "net/url" + "strings" + "time" + + "github.com/QuantumNous/new-api/constant" + + "github.com/gin-gonic/gin" +) + +const KeyRequestBody = "key_request_body" + +func GetRequestBody(c *gin.Context) ([]byte, error) { + requestBody, _ := c.Get(KeyRequestBody) + if requestBody != nil { + return requestBody.([]byte), nil + } + requestBody, err := io.ReadAll(c.Request.Body) + if err != nil { + return nil, err + } + _ = c.Request.Body.Close() + c.Set(KeyRequestBody, requestBody) + return requestBody.([]byte), nil +} + +func UnmarshalBodyReusable(c *gin.Context, v any) error { + requestBody, err := GetRequestBody(c) + if err != nil { + return err + } + //if DebugEnabled { + // println("UnmarshalBodyReusable request body:", string(requestBody)) + //} + contentType := c.Request.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "application/json") { + err = Unmarshal(requestBody, v) + } else if strings.Contains(contentType, gin.MIMEPOSTForm) { + err = parseFormData(requestBody, v) + } else if strings.Contains(contentType, gin.MIMEMultipartPOSTForm) { + err = parseMultipartFormData(c, requestBody, v) + } else { + // skip for now + // TODO: someday non json request have variant model, we will need to implementation this + } + if err != nil { + return err + } + // Reset request body + c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) + return nil +} + +func SetContextKey(c *gin.Context, key constant.ContextKey, value any) { + c.Set(string(key), value) +} + +func GetContextKey(c *gin.Context, key constant.ContextKey) (any, bool) { + return c.Get(string(key)) +} + +func GetContextKeyString(c *gin.Context, key constant.ContextKey) string { + return c.GetString(string(key)) +} + +func GetContextKeyInt(c *gin.Context, key constant.ContextKey) int { + return c.GetInt(string(key)) +} + +func GetContextKeyBool(c *gin.Context, key constant.ContextKey) bool { + return c.GetBool(string(key)) +} + +func GetContextKeyStringSlice(c *gin.Context, key constant.ContextKey) []string { + return c.GetStringSlice(string(key)) +} + +func GetContextKeyStringMap(c *gin.Context, key constant.ContextKey) map[string]any { + return c.GetStringMap(string(key)) +} + +func GetContextKeyTime(c *gin.Context, key constant.ContextKey) time.Time { + return c.GetTime(string(key)) +} + +func GetContextKeyType[T any](c *gin.Context, key constant.ContextKey) (T, bool) { + if value, ok := c.Get(string(key)); ok { + if v, ok := value.(T); ok { + return v, true + } + } + var t T + return t, false +} + +func ApiError(c *gin.Context, err error) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) +} + +func ApiErrorMsg(c *gin.Context, msg string) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": msg, + }) +} + +func ApiSuccess(c *gin.Context, data any) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": data, + }) +} + +func ParseMultipartFormReusable(c *gin.Context) (*multipart.Form, error) { + requestBody, err := GetRequestBody(c) + if err != nil { + return nil, err + } + + contentType := c.Request.Header.Get("Content-Type") + boundary, err := parseBoundary(contentType) + if err != nil { + return nil, err + } + + reader := multipart.NewReader(bytes.NewReader(requestBody), boundary) + form, err := reader.ReadForm(multipartMemoryLimit()) + if err != nil { + return nil, err + } + + // Reset request body + c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) + return form, nil +} + +func processFormMap(formMap map[string]any, v any) error { + jsonData, err := Marshal(formMap) + if err != nil { + return err + } + + err = Unmarshal(jsonData, v) + if err != nil { + return err + } + + return nil +} + +func parseFormData(data []byte, v any) error { + values, err := url.ParseQuery(string(data)) + if err != nil { + return err + } + formMap := make(map[string]any) + for key, vals := range values { + if len(vals) == 1 { + formMap[key] = vals[0] + } else { + formMap[key] = vals + } + } + + return processFormMap(formMap, v) +} + +func parseMultipartFormData(c *gin.Context, data []byte, v any) error { + contentType := c.Request.Header.Get("Content-Type") + boundary, err := parseBoundary(contentType) + if err != nil { + if errors.Is(err, errBoundaryNotFound) { + return Unmarshal(data, v) // Fallback to JSON + } + return err + } + + reader := multipart.NewReader(bytes.NewReader(data), boundary) + form, err := reader.ReadForm(multipartMemoryLimit()) + if err != nil { + return err + } + defer form.RemoveAll() + formMap := make(map[string]any) + for key, vals := range form.Value { + if len(vals) == 1 { + formMap[key] = vals[0] + } else { + formMap[key] = vals + } + } + + return processFormMap(formMap, v) +} + +var errBoundaryNotFound = errors.New("multipart boundary not found") + +// parseBoundary extracts the multipart boundary from the Content-Type header using mime.ParseMediaType +func parseBoundary(contentType string) (string, error) { + if contentType == "" { + return "", errBoundaryNotFound + } + // Boundary-UUID / boundary-------xxxxxx + _, params, err := mime.ParseMediaType(contentType) + if err != nil { + return "", err + } + boundary, ok := params["boundary"] + if !ok || boundary == "" { + return "", errBoundaryNotFound + } + return boundary, nil +} + +// multipartMemoryLimit returns the configured multipart memory limit in bytes +func multipartMemoryLimit() int64 { + limitMB := constant.MaxFileDownloadMB + if limitMB <= 0 { + limitMB = 32 + } + return int64(limitMB) << 20 +} diff --git a/common/go-channel.go b/common/go-channel.go new file mode 100644 index 0000000000000000000000000000000000000000..f9168fc4674e5f53e8168663da366aaa10649bb9 --- /dev/null +++ b/common/go-channel.go @@ -0,0 +1,53 @@ +package common + +import ( + "time" +) + +func SafeSendBool(ch chan bool, value bool) (closed bool) { + defer func() { + // Recover from panic if one occured. A panic would mean the channel was closed. + if recover() != nil { + closed = true + } + }() + + // This will panic if the channel is closed. + ch <- value + + // If the code reaches here, then the channel was not closed. + return false +} + +func SafeSendString(ch chan string, value string) (closed bool) { + defer func() { + // Recover from panic if one occured. A panic would mean the channel was closed. + if recover() != nil { + closed = true + } + }() + + // This will panic if the channel is closed. + ch <- value + + // If the code reaches here, then the channel was not closed. + return false +} + +// SafeSendStringTimeout send, return true, else return false +func SafeSendStringTimeout(ch chan string, value string, timeout int) (closed bool) { + defer func() { + // Recover from panic if one occured. A panic would mean the channel was closed. + if recover() != nil { + closed = false + } + }() + + // This will panic if the channel is closed. + select { + case ch <- value: + return true + case <-time.After(time.Duration(timeout) * time.Second): + return false + } +} diff --git a/common/gopool.go b/common/gopool.go new file mode 100644 index 0000000000000000000000000000000000000000..d410380b86d1c0ed52340f2f6053e4d04c0b2361 --- /dev/null +++ b/common/gopool.go @@ -0,0 +1,25 @@ +package common + +import ( + "context" + "fmt" + "math" + + "github.com/bytedance/gopkg/util/gopool" +) + +var relayGoPool gopool.Pool + +func init() { + relayGoPool = gopool.NewPool("gopool.RelayPool", math.MaxInt32, gopool.NewConfig()) + relayGoPool.SetPanicHandler(func(ctx context.Context, i interface{}) { + if stopChan, ok := ctx.Value("stop_chan").(chan bool); ok { + SafeSendBool(stopChan, true) + } + SysError(fmt.Sprintf("panic in gopool.RelayPool: %v", i)) + }) +} + +func RelayCtxGo(ctx context.Context, f func()) { + relayGoPool.CtxGo(ctx, f) +} diff --git a/common/hash.go b/common/hash.go new file mode 100644 index 0000000000000000000000000000000000000000..5019193857364f4f914dd956afdc5efd3fb9fcff --- /dev/null +++ b/common/hash.go @@ -0,0 +1,34 @@ +package common + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" +) + +func Sha256Raw(data []byte) []byte { + h := sha256.New() + h.Write(data) + return h.Sum(nil) +} + +func Sha1Raw(data []byte) []byte { + h := sha1.New() + h.Write(data) + return h.Sum(nil) +} + +func Sha1(data []byte) string { + return hex.EncodeToString(Sha1Raw(data)) +} + +func HmacSha256Raw(message, key []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(message) + return h.Sum(nil) +} + +func HmacSha256(message, key string) string { + return hex.EncodeToString(HmacSha256Raw([]byte(message), []byte(key))) +} diff --git a/common/init.go b/common/init.go new file mode 100644 index 0000000000000000000000000000000000000000..687af6e76814dbf9cf3a138a5caf80749652c49e --- /dev/null +++ b/common/init.go @@ -0,0 +1,144 @@ +package common + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/constant" +) + +var ( + Port = flag.Int("port", 3000, "the listening port") + PrintVersion = flag.Bool("version", false, "print version and exit") + PrintHelp = flag.Bool("help", false, "print help and exit") + LogDir = flag.String("log-dir", "./logs", "specify the log directory") +) + +func printHelp() { + fmt.Println("NewAPI(Based OneAPI) " + Version + " - The next-generation LLM gateway and AI asset management system supports multiple languages.") + fmt.Println("Original Project: OneAPI by JustSong - https://github.com/songquanpeng/one-api") + fmt.Println("Maintainer: QuantumNous - https://github.com/QuantumNous/new-api") + fmt.Println("Usage: newapi [--port ] [--log-dir ] [--version] [--help]") +} + +func InitEnv() { + flag.Parse() + + envVersion := os.Getenv("VERSION") + if envVersion != "" { + Version = envVersion + } + + if *PrintVersion { + fmt.Println(Version) + os.Exit(0) + } + + if *PrintHelp { + printHelp() + os.Exit(0) + } + + if os.Getenv("SESSION_SECRET") != "" { + ss := os.Getenv("SESSION_SECRET") + if ss == "random_string" { + log.Println("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string.") + log.Println("警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。") + log.Fatal("Please set SESSION_SECRET to a random string.") + } else { + SessionSecret = ss + } + } + if os.Getenv("CRYPTO_SECRET") != "" { + CryptoSecret = os.Getenv("CRYPTO_SECRET") + } else { + CryptoSecret = SessionSecret + } + if os.Getenv("SQLITE_PATH") != "" { + SQLitePath = os.Getenv("SQLITE_PATH") + } + if *LogDir != "" { + var err error + *LogDir, err = filepath.Abs(*LogDir) + if err != nil { + log.Fatal(err) + } + if _, err := os.Stat(*LogDir); os.IsNotExist(err) { + err = os.Mkdir(*LogDir, 0777) + if err != nil { + log.Fatal(err) + } + } + } + + // Initialize variables from constants.go that were using environment variables + DebugEnabled = os.Getenv("DEBUG") == "true" + MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true" + IsMasterNode = os.Getenv("NODE_TYPE") != "slave" + + // Parse requestInterval and set RequestInterval + requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL")) + RequestInterval = time.Duration(requestInterval) * time.Second + + // Initialize variables with GetEnvOrDefault + SyncFrequency = GetEnvOrDefault("SYNC_FREQUENCY", 60) + BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5) + RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0) + + // Initialize string variables with GetEnvOrDefaultString + GeminiSafetySetting = GetEnvOrDefaultString("GEMINI_SAFETY_SETTING", "BLOCK_NONE") + CohereSafetySetting = GetEnvOrDefaultString("COHERE_SAFETY_SETTING", "NONE") + + // Initialize rate limit variables + GlobalApiRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_API_RATE_LIMIT_ENABLE", true) + GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 180) + GlobalApiRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_API_RATE_LIMIT_DURATION", 180)) + + GlobalWebRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_WEB_RATE_LIMIT_ENABLE", true) + GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60) + GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180)) + + CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true) + CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20) + CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60)) + initConstantEnv() +} + +func initConstantEnv() { + constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300) + constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true) + constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20) + // ForceStreamOption 覆盖请求参数,强制返回usage信息 + constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true) + constant.CountToken = GetEnvOrDefaultBool("CountToken", true) + constant.GetMediaToken = GetEnvOrDefaultBool("GET_MEDIA_TOKEN", true) + constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", false) + constant.UpdateTask = GetEnvOrDefaultBool("UPDATE_TASK", true) + constant.AzureDefaultAPIVersion = GetEnvOrDefaultString("AZURE_DEFAULT_API_VERSION", "2025-04-01-preview") + constant.GeminiVisionMaxImageNum = GetEnvOrDefault("GEMINI_VISION_MAX_IMAGE_NUM", 16) + constant.NotifyLimitCount = GetEnvOrDefault("NOTIFY_LIMIT_COUNT", 2) + constant.NotificationLimitDurationMinute = GetEnvOrDefault("NOTIFICATION_LIMIT_DURATION_MINUTE", 10) + // GenerateDefaultToken 是否生成初始令牌,默认关闭。 + constant.GenerateDefaultToken = GetEnvOrDefaultBool("GENERATE_DEFAULT_TOKEN", false) + // 是否启用错误日志 + constant.ErrorLogEnabled = GetEnvOrDefaultBool("ERROR_LOG_ENABLED", false) + + soraPatchStr := GetEnvOrDefaultString("TASK_PRICE_PATCH", "") + if soraPatchStr != "" { + var taskPricePatches []string + soraPatches := strings.Split(soraPatchStr, ",") + for _, patch := range soraPatches { + trimmedPatch := strings.TrimSpace(patch) + if trimmedPatch != "" { + taskPricePatches = append(taskPricePatches, trimmedPatch) + } + } + constant.TaskPricePatches = taskPricePatches + } +} diff --git a/common/ip.go b/common/ip.go new file mode 100644 index 0000000000000000000000000000000000000000..bfb64ee7f8d8f1b48bfbc2bdc0b206fd44bdb415 --- /dev/null +++ b/common/ip.go @@ -0,0 +1,22 @@ +package common + +import "net" + +func IsPrivateIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + + private := []net.IPNet{ + {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, + {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, + {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, + } + + for _, privateNet := range private { + if privateNet.Contains(ip) { + return true + } + } + return false +} diff --git a/common/json.go b/common/json.go new file mode 100644 index 0000000000000000000000000000000000000000..a65da462e2dda86f0926e890ba4098849cb727db --- /dev/null +++ b/common/json.go @@ -0,0 +1,45 @@ +package common + +import ( + "bytes" + "encoding/json" + "io" +) + +func Unmarshal(data []byte, v any) error { + return json.Unmarshal(data, v) +} + +func UnmarshalJsonStr(data string, v any) error { + return json.Unmarshal(StringToByteSlice(data), v) +} + +func DecodeJson(reader io.Reader, v any) error { + return json.NewDecoder(reader).Decode(v) +} + +func Marshal(v any) ([]byte, error) { + return json.Marshal(v) +} + +func GetJsonType(data json.RawMessage) string { + data = bytes.TrimSpace(data) + if len(data) == 0 { + return "unknown" + } + firstChar := bytes.TrimSpace(data)[0] + switch firstChar { + case '{': + return "object" + case '[': + return "array" + case '"': + return "string" + case 't', 'f': + return "boolean" + case 'n': + return "null" + default: + return "number" + } +} diff --git a/common/limiter/limiter.go b/common/limiter/limiter.go new file mode 100644 index 0000000000000000000000000000000000000000..6be61bc9513efccb04c879f8e04496f7ed40cc83 --- /dev/null +++ b/common/limiter/limiter.go @@ -0,0 +1,90 @@ +package limiter + +import ( + "context" + _ "embed" + "fmt" + "sync" + + "github.com/QuantumNous/new-api/common" + "github.com/go-redis/redis/v8" +) + +//go:embed lua/rate_limit.lua +var rateLimitScript string + +type RedisLimiter struct { + client *redis.Client + limitScriptSHA string +} + +var ( + instance *RedisLimiter + once sync.Once +) + +func New(ctx context.Context, r *redis.Client) *RedisLimiter { + once.Do(func() { + // 预加载脚本 + limitSHA, err := r.ScriptLoad(ctx, rateLimitScript).Result() + if err != nil { + common.SysLog(fmt.Sprintf("Failed to load rate limit script: %v", err)) + } + instance = &RedisLimiter{ + client: r, + limitScriptSHA: limitSHA, + } + }) + + return instance +} + +func (rl *RedisLimiter) Allow(ctx context.Context, key string, opts ...Option) (bool, error) { + // 默认配置 + config := &Config{ + Capacity: 10, + Rate: 1, + Requested: 1, + } + + // 应用选项模式 + for _, opt := range opts { + opt(config) + } + + // 执行限流 + result, err := rl.client.EvalSha( + ctx, + rl.limitScriptSHA, + []string{key}, + config.Requested, + config.Rate, + config.Capacity, + ).Int() + + if err != nil { + return false, fmt.Errorf("rate limit failed: %w", err) + } + return result == 1, nil +} + +// Config 配置选项模式 +type Config struct { + Capacity int64 + Rate int64 + Requested int64 +} + +type Option func(*Config) + +func WithCapacity(c int64) Option { + return func(cfg *Config) { cfg.Capacity = c } +} + +func WithRate(r int64) Option { + return func(cfg *Config) { cfg.Rate = r } +} + +func WithRequested(n int64) Option { + return func(cfg *Config) { cfg.Requested = n } +} diff --git a/common/limiter/lua/rate_limit.lua b/common/limiter/lua/rate_limit.lua new file mode 100644 index 0000000000000000000000000000000000000000..c07fd3a8970a285eb8f5591698539b691b528418 --- /dev/null +++ b/common/limiter/lua/rate_limit.lua @@ -0,0 +1,44 @@ +-- 令牌桶限流器 +-- KEYS[1]: 限流器唯一标识 +-- ARGV[1]: 请求令牌数 (通常为1) +-- ARGV[2]: 令牌生成速率 (每秒) +-- ARGV[3]: 桶容量 + +local key = KEYS[1] +local requested = tonumber(ARGV[1]) +local rate = tonumber(ARGV[2]) +local capacity = tonumber(ARGV[3]) + +-- 获取当前时间(Redis服务器时间) +local now = redis.call('TIME') +local nowInSeconds = tonumber(now[1]) + +-- 获取桶状态 +local bucket = redis.call('HMGET', key, 'tokens', 'last_time') +local tokens = tonumber(bucket[1]) +local last_time = tonumber(bucket[2]) + +-- 初始化桶(首次请求或过期) +if not tokens or not last_time then + tokens = capacity + last_time = nowInSeconds +else + -- 计算新增令牌 + local elapsed = nowInSeconds - last_time + local add_tokens = elapsed * rate + tokens = math.min(capacity, tokens + add_tokens) + last_time = nowInSeconds +end + +-- 判断是否允许请求 +local allowed = false +if tokens >= requested then + tokens = tokens - requested + allowed = true +end + +---- 更新桶状态并设置过期时间 +redis.call('HMSET', key, 'tokens', tokens, 'last_time', last_time) +--redis.call('EXPIRE', key, math.ceil(capacity / rate) + 60) -- 适当延长过期时间 + +return allowed and 1 or 0 \ No newline at end of file diff --git a/common/model.go b/common/model.go new file mode 100644 index 0000000000000000000000000000000000000000..14ca19115dbac620e37267fcf1035ddf65dabc2b --- /dev/null +++ b/common/model.go @@ -0,0 +1,42 @@ +package common + +import "strings" + +var ( + // OpenAIResponseOnlyModels is a list of models that are only available for OpenAI responses. + OpenAIResponseOnlyModels = []string{ + "o3-pro", + "o3-deep-research", + "o4-mini-deep-research", + } + ImageGenerationModels = []string{ + "dall-e-3", + "dall-e-2", + "gpt-image-1", + "prefix:imagen-", + "flux-", + "flux.1-", + } +) + +func IsOpenAIResponseOnlyModel(modelName string) bool { + for _, m := range OpenAIResponseOnlyModels { + if strings.Contains(modelName, m) { + return true + } + } + return false +} + +func IsImageGenerationModel(modelName string) bool { + modelName = strings.ToLower(modelName) + for _, m := range ImageGenerationModels { + if strings.Contains(modelName, m) { + return true + } + if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) { + return true + } + } + return false +} diff --git a/common/page_info.go b/common/page_info.go new file mode 100644 index 0000000000000000000000000000000000000000..2378a5d81fdbc7f10cab4258fa72795ea04ccd83 --- /dev/null +++ b/common/page_info.go @@ -0,0 +1,82 @@ +package common + +import ( + "strconv" + + "github.com/gin-gonic/gin" +) + +type PageInfo struct { + Page int `json:"page"` // page num 页码 + PageSize int `json:"page_size"` // page size 页大小 + + Total int `json:"total"` // 总条数,后设置 + Items any `json:"items"` // 数据,后设置 +} + +func (p *PageInfo) GetStartIdx() int { + return (p.Page - 1) * p.PageSize +} + +func (p *PageInfo) GetEndIdx() int { + return p.Page * p.PageSize +} + +func (p *PageInfo) GetPageSize() int { + return p.PageSize +} + +func (p *PageInfo) GetPage() int { + return p.Page +} + +func (p *PageInfo) SetTotal(total int) { + p.Total = total +} + +func (p *PageInfo) SetItems(items any) { + p.Items = items +} + +func GetPageQuery(c *gin.Context) *PageInfo { + pageInfo := &PageInfo{} + // 手动获取并处理每个参数 + if page, err := strconv.Atoi(c.Query("p")); err == nil { + pageInfo.Page = page + } + if pageSize, err := strconv.Atoi(c.Query("page_size")); err == nil { + pageInfo.PageSize = pageSize + } + if pageInfo.Page < 1 { + // 兼容 + page, _ := strconv.Atoi(c.Query("p")) + if page != 0 { + pageInfo.Page = page + } else { + pageInfo.Page = 1 + } + } + + if pageInfo.PageSize == 0 { + // 兼容 + pageSize, _ := strconv.Atoi(c.Query("ps")) + if pageSize != 0 { + pageInfo.PageSize = pageSize + } + if pageInfo.PageSize == 0 { + pageSize, _ = strconv.Atoi(c.Query("size")) // token page + if pageSize != 0 { + pageInfo.PageSize = pageSize + } + } + if pageInfo.PageSize == 0 { + pageInfo.PageSize = ItemsPerPage + } + } + + if pageInfo.PageSize > 100 { + pageInfo.PageSize = 100 + } + + return pageInfo +} diff --git a/common/pprof.go b/common/pprof.go new file mode 100644 index 0000000000000000000000000000000000000000..745926536f3357213df5149fa23a414658005ae4 --- /dev/null +++ b/common/pprof.go @@ -0,0 +1,45 @@ +package common + +import ( + "fmt" + "os" + "runtime/pprof" + "time" + + "github.com/shirou/gopsutil/cpu" +) + +// Monitor 定时监控cpu使用率,超过阈值输出pprof文件 +func Monitor() { + for { + percent, err := cpu.Percent(time.Second, false) + if err != nil { + panic(err) + } + if percent[0] > 80 { + fmt.Println("cpu usage too high") + // write pprof file + if _, err := os.Stat("./pprof"); os.IsNotExist(err) { + err := os.Mkdir("./pprof", os.ModePerm) + if err != nil { + SysLog("创建pprof文件夹失败 " + err.Error()) + continue + } + } + f, err := os.Create("./pprof/" + fmt.Sprintf("cpu-%s.pprof", time.Now().Format("20060102150405"))) + if err != nil { + SysLog("创建pprof文件失败 " + err.Error()) + continue + } + err = pprof.StartCPUProfile(f) + if err != nil { + SysLog("启动pprof失败 " + err.Error()) + continue + } + time.Sleep(10 * time.Second) // profile for 30 seconds + pprof.StopCPUProfile() + f.Close() + } + time.Sleep(30 * time.Second) + } +} diff --git a/common/quota.go b/common/quota.go new file mode 100644 index 0000000000000000000000000000000000000000..dfd65d273ee57df0998ab633b7876fb3367664d5 --- /dev/null +++ b/common/quota.go @@ -0,0 +1,5 @@ +package common + +func GetTrustQuota() int { + return int(10 * QuotaPerUnit) +} diff --git a/common/rate-limit.go b/common/rate-limit.go new file mode 100644 index 0000000000000000000000000000000000000000..301c101c974809a660c4230843c2ae43289c6464 --- /dev/null +++ b/common/rate-limit.go @@ -0,0 +1,70 @@ +package common + +import ( + "sync" + "time" +) + +type InMemoryRateLimiter struct { + store map[string]*[]int64 + mutex sync.Mutex + expirationDuration time.Duration +} + +func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) { + if l.store == nil { + l.mutex.Lock() + if l.store == nil { + l.store = make(map[string]*[]int64) + l.expirationDuration = expirationDuration + if expirationDuration > 0 { + go l.clearExpiredItems() + } + } + l.mutex.Unlock() + } +} + +func (l *InMemoryRateLimiter) clearExpiredItems() { + for { + time.Sleep(l.expirationDuration) + l.mutex.Lock() + now := time.Now().Unix() + for key := range l.store { + queue := l.store[key] + size := len(*queue) + if size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) { + delete(l.store, key) + } + } + l.mutex.Unlock() + } +} + +// Request parameter duration's unit is seconds +func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool { + l.mutex.Lock() + defer l.mutex.Unlock() + // [old <-- new] + queue, ok := l.store[key] + now := time.Now().Unix() + if ok { + if len(*queue) < maxRequestNum { + *queue = append(*queue, now) + return true + } else { + if now-(*queue)[0] >= duration { + *queue = (*queue)[1:] + *queue = append(*queue, now) + return true + } else { + return false + } + } + } else { + s := make([]int64, 0, maxRequestNum) + l.store[key] = &s + *(l.store[key]) = append(*(l.store[key]), now) + } + return true +} diff --git a/common/redis.go b/common/redis.go new file mode 100644 index 0000000000000000000000000000000000000000..c72878378fcef1215fed0045e4ce43fae0dea051 --- /dev/null +++ b/common/redis.go @@ -0,0 +1,327 @@ +package common + +import ( + "context" + "errors" + "fmt" + "os" + "reflect" + "strconv" + "time" + + "github.com/go-redis/redis/v8" + "gorm.io/gorm" +) + +var RDB *redis.Client +var RedisEnabled = true + +func RedisKeyCacheSeconds() int { + return SyncFrequency +} + +// InitRedisClient This function is called after init() +func InitRedisClient() (err error) { + if os.Getenv("REDIS_CONN_STRING") == "" { + RedisEnabled = false + SysLog("REDIS_CONN_STRING not set, Redis is not enabled") + return nil + } + if os.Getenv("SYNC_FREQUENCY") == "" { + SysLog("SYNC_FREQUENCY not set, use default value 60") + SyncFrequency = 60 + } + SysLog("Redis is enabled") + opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING")) + if err != nil { + FatalLog("failed to parse Redis connection string: " + err.Error()) + } + opt.PoolSize = GetEnvOrDefault("REDIS_POOL_SIZE", 10) + RDB = redis.NewClient(opt) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err = RDB.Ping(ctx).Result() + if err != nil { + FatalLog("Redis ping test failed: " + err.Error()) + } + if DebugEnabled { + SysLog(fmt.Sprintf("Redis connected to %s", opt.Addr)) + SysLog(fmt.Sprintf("Redis database: %d", opt.DB)) + } + return err +} + +func ParseRedisOption() *redis.Options { + opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING")) + if err != nil { + FatalLog("failed to parse Redis connection string: " + err.Error()) + } + return opt +} + +func RedisSet(key string, value string, expiration time.Duration) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis SET: key=%s, value=%s, expiration=%v", key, value, expiration)) + } + ctx := context.Background() + return RDB.Set(ctx, key, value, expiration).Err() +} + +func RedisGet(key string) (string, error) { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis GET: key=%s", key)) + } + ctx := context.Background() + val, err := RDB.Get(ctx, key).Result() + return val, err +} + +//func RedisExpire(key string, expiration time.Duration) error { +// ctx := context.Background() +// return RDB.Expire(ctx, key, expiration).Err() +//} +// +//func RedisGetEx(key string, expiration time.Duration) (string, error) { +// ctx := context.Background() +// return RDB.GetSet(ctx, key, expiration).Result() +//} + +func RedisDel(key string) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis DEL: key=%s", key)) + } + ctx := context.Background() + return RDB.Del(ctx, key).Err() +} + +func RedisDelKey(key string) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key)) + } + ctx := context.Background() + return RDB.Del(ctx, key).Err() +} + +func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis HSET: key=%s, obj=%+v, expiration=%v", key, obj, expiration)) + } + ctx := context.Background() + + data := make(map[string]interface{}) + + // 使用反射遍历结构体字段 + v := reflect.ValueOf(obj).Elem() + t := v.Type() + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + value := v.Field(i) + + // Skip DeletedAt field + if field.Type.String() == "gorm.DeletedAt" { + continue + } + + // 处理指针类型 + if value.Kind() == reflect.Ptr { + if value.IsNil() { + data[field.Name] = "" + continue + } + value = value.Elem() + } + + // 处理布尔类型 + if value.Kind() == reflect.Bool { + data[field.Name] = strconv.FormatBool(value.Bool()) + continue + } + + // 其他类型直接转换为字符串 + data[field.Name] = fmt.Sprintf("%v", value.Interface()) + } + + txn := RDB.TxPipeline() + txn.HSet(ctx, key, data) + + // 只有在 expiration 大于 0 时才设置过期时间 + if expiration > 0 { + txn.Expire(ctx, key, expiration) + } + + _, err := txn.Exec(ctx) + if err != nil { + return fmt.Errorf("failed to execute transaction: %w", err) + } + return nil +} + +func RedisHGetObj(key string, obj interface{}) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis HGETALL: key=%s", key)) + } + ctx := context.Background() + + result, err := RDB.HGetAll(ctx, key).Result() + if err != nil { + return fmt.Errorf("failed to load hash from Redis: %w", err) + } + + if len(result) == 0 { + return fmt.Errorf("key %s not found in Redis", key) + } + + // Handle both pointer and non-pointer values + val := reflect.ValueOf(obj) + if val.Kind() != reflect.Ptr { + return fmt.Errorf("obj must be a pointer to a struct, got %T", obj) + } + + v := val.Elem() + if v.Kind() != reflect.Struct { + return fmt.Errorf("obj must be a pointer to a struct, got pointer to %T", v.Interface()) + } + + t := v.Type() + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + fieldName := field.Name + if value, ok := result[fieldName]; ok { + fieldValue := v.Field(i) + + // Handle pointer types + if fieldValue.Kind() == reflect.Ptr { + if value == "" { + continue + } + if fieldValue.IsNil() { + fieldValue.Set(reflect.New(fieldValue.Type().Elem())) + } + fieldValue = fieldValue.Elem() + } + + // Enhanced type handling for Token struct + switch fieldValue.Kind() { + case reflect.String: + fieldValue.SetString(value) + case reflect.Int, reflect.Int64: + intValue, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return fmt.Errorf("failed to parse int field %s: %w", fieldName, err) + } + fieldValue.SetInt(intValue) + case reflect.Bool: + boolValue, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("failed to parse bool field %s: %w", fieldName, err) + } + fieldValue.SetBool(boolValue) + case reflect.Struct: + // Special handling for gorm.DeletedAt + if fieldValue.Type().String() == "gorm.DeletedAt" { + if value != "" { + timeValue, err := time.Parse(time.RFC3339, value) + if err != nil { + return fmt.Errorf("failed to parse DeletedAt field %s: %w", fieldName, err) + } + fieldValue.Set(reflect.ValueOf(gorm.DeletedAt{Time: timeValue, Valid: true})) + } + } + default: + return fmt.Errorf("unsupported field type: %s for field %s", fieldValue.Kind(), fieldName) + } + } + } + + return nil +} + +// RedisIncr Add this function to handle atomic increments +func RedisIncr(key string, delta int64) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis INCR: key=%s, delta=%d", key, delta)) + } + // 检查键的剩余生存时间 + ttlCmd := RDB.TTL(context.Background(), key) + ttl, err := ttlCmd.Result() + if err != nil && !errors.Is(err, redis.Nil) { + return fmt.Errorf("failed to get TTL: %w", err) + } + + // 只有在 key 存在且有 TTL 时才需要特殊处理 + if ttl > 0 { + ctx := context.Background() + // 开始一个Redis事务 + txn := RDB.TxPipeline() + + // 减少余额 + decrCmd := txn.IncrBy(ctx, key, delta) + if err := decrCmd.Err(); err != nil { + return err // 如果减少失败,则直接返回错误 + } + + // 重新设置过期时间,使用原来的过期时间 + txn.Expire(ctx, key, ttl) + + // 执行事务 + _, err = txn.Exec(ctx) + return err + } + return nil +} + +func RedisHIncrBy(key, field string, delta int64) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis HINCRBY: key=%s, field=%s, delta=%d", key, field, delta)) + } + ttlCmd := RDB.TTL(context.Background(), key) + ttl, err := ttlCmd.Result() + if err != nil && !errors.Is(err, redis.Nil) { + return fmt.Errorf("failed to get TTL: %w", err) + } + + if ttl > 0 { + ctx := context.Background() + txn := RDB.TxPipeline() + + incrCmd := txn.HIncrBy(ctx, key, field, delta) + if err := incrCmd.Err(); err != nil { + return err + } + + txn.Expire(ctx, key, ttl) + + _, err = txn.Exec(ctx) + return err + } + return nil +} + +func RedisHSetField(key, field string, value interface{}) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis HSET field: key=%s, field=%s, value=%v", key, field, value)) + } + ttlCmd := RDB.TTL(context.Background(), key) + ttl, err := ttlCmd.Result() + if err != nil && !errors.Is(err, redis.Nil) { + return fmt.Errorf("failed to get TTL: %w", err) + } + + if ttl > 0 { + ctx := context.Background() + txn := RDB.TxPipeline() + + hsetCmd := txn.HSet(ctx, key, field, value) + if err := hsetCmd.Err(); err != nil { + return err + } + + txn.Expire(ctx, key, ttl) + + _, err = txn.Exec(ctx) + return err + } + return nil +} diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go new file mode 100644 index 0000000000000000000000000000000000000000..6f7d289f1a5bff1387bc2e336ae435aa4d8131eb --- /dev/null +++ b/common/ssrf_protection.go @@ -0,0 +1,327 @@ +package common + +import ( + "fmt" + "net" + "net/url" + "strconv" + "strings" +) + +// SSRFProtection SSRF防护配置 +type SSRFProtection struct { + AllowPrivateIp bool + DomainFilterMode bool // true: 白名单, false: 黑名单 + DomainList []string // domain format, e.g. example.com, *.example.com + IpFilterMode bool // true: 白名单, false: 黑名单 + IpList []string // CIDR or single IP + AllowedPorts []int // 允许的端口范围 + ApplyIPFilterForDomain bool // 对域名启用IP过滤 +} + +// DefaultSSRFProtection 默认SSRF防护配置 +var DefaultSSRFProtection = &SSRFProtection{ + AllowPrivateIp: false, + DomainFilterMode: true, + DomainList: []string{}, + IpFilterMode: true, + IpList: []string{}, + AllowedPorts: []int{}, +} + +// isPrivateIP 检查IP是否为私有地址 +func isPrivateIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + + // 检查私有网段 + private := []net.IPNet{ + {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8 + {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12 + {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16 + {IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8 + {IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地) + {IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播) + {IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留) + } + + for _, privateNet := range private { + if privateNet.Contains(ip) { + return true + } + } + + // 检查IPv6私有地址 + if ip.To4() == nil { + // IPv6 loopback + if ip.Equal(net.IPv6loopback) { + return true + } + // IPv6 link-local + if strings.HasPrefix(ip.String(), "fe80:") { + return true + } + // IPv6 unique local + if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") { + return true + } + } + + return false +} + +// parsePortRanges 解析端口范围配置 +// 支持格式: "80", "443", "8000-9000" +func parsePortRanges(portConfigs []string) ([]int, error) { + var ports []int + + for _, config := range portConfigs { + config = strings.TrimSpace(config) + if config == "" { + continue + } + + if strings.Contains(config, "-") { + // 处理端口范围 "8000-9000" + parts := strings.Split(config, "-") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid port range format: %s", config) + } + + startPort, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + return nil, fmt.Errorf("invalid start port in range %s: %v", config, err) + } + + endPort, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil { + return nil, fmt.Errorf("invalid end port in range %s: %v", config, err) + } + + if startPort > endPort { + return nil, fmt.Errorf("invalid port range %s: start port cannot be greater than end port", config) + } + + if startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 { + return nil, fmt.Errorf("port range %s contains invalid port numbers (must be 1-65535)", config) + } + + // 添加范围内的所有端口 + for port := startPort; port <= endPort; port++ { + ports = append(ports, port) + } + } else { + // 处理单个端口 "80" + port, err := strconv.Atoi(config) + if err != nil { + return nil, fmt.Errorf("invalid port number: %s", config) + } + + if port < 1 || port > 65535 { + return nil, fmt.Errorf("invalid port number %d (must be 1-65535)", port) + } + + ports = append(ports, port) + } + } + + return ports, nil +} + +// isAllowedPort 检查端口是否被允许 +func (p *SSRFProtection) isAllowedPort(port int) bool { + if len(p.AllowedPorts) == 0 { + return true // 如果没有配置端口限制,则允许所有端口 + } + + for _, allowedPort := range p.AllowedPorts { + if port == allowedPort { + return true + } + } + return false +} + +// isDomainWhitelisted 检查域名是否在白名单中 +func isDomainListed(domain string, list []string) bool { + if len(list) == 0 { + return false + } + + domain = strings.ToLower(domain) + for _, item := range list { + item = strings.ToLower(strings.TrimSpace(item)) + if item == "" { + continue + } + // 精确匹配 + if domain == item { + return true + } + // 通配符匹配 (*.example.com) + if strings.HasPrefix(item, "*.") { + suffix := strings.TrimPrefix(item, "*.") + if strings.HasSuffix(domain, "."+suffix) || domain == suffix { + return true + } + } + } + return false +} + +func (p *SSRFProtection) isDomainAllowed(domain string) bool { + listed := isDomainListed(domain, p.DomainList) + if p.DomainFilterMode { // 白名单 + return listed + } + // 黑名单 + return !listed +} + +// isIPWhitelisted 检查IP是否在白名单中 + +func isIPListed(ip net.IP, list []string) bool { + if len(list) == 0 { + return false + } + + for _, whitelistCIDR := range list { + _, network, err := net.ParseCIDR(whitelistCIDR) + if err != nil { + // 尝试作为单个IP处理 + if whitelistIP := net.ParseIP(whitelistCIDR); whitelistIP != nil { + if ip.Equal(whitelistIP) { + return true + } + } + continue + } + + if network.Contains(ip) { + return true + } + } + return false +} + +// IsIPAccessAllowed 检查IP是否允许访问 +func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool { + // 私有IP限制 + if isPrivateIP(ip) && !p.AllowPrivateIp { + return false + } + + listed := isIPListed(ip, p.IpList) + if p.IpFilterMode { // 白名单 + return listed + } + // 黑名单 + return !listed +} + +// ValidateURL 验证URL是否安全 +func (p *SSRFProtection) ValidateURL(urlStr string) error { + // 解析URL + u, err := url.Parse(urlStr) + if err != nil { + return fmt.Errorf("invalid URL format: %v", err) + } + + // 只允许HTTP/HTTPS协议 + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme) + } + + // 解析主机和端口 + host, portStr, err := net.SplitHostPort(u.Host) + if err != nil { + // 没有端口,使用默认端口 + host = u.Hostname() + if u.Scheme == "https" { + portStr = "443" + } else { + portStr = "80" + } + } + + // 验证端口 + port, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("invalid port: %s", portStr) + } + + if !p.isAllowedPort(port) { + return fmt.Errorf("port %d is not allowed", port) + } + + // 如果 host 是 IP,则跳过域名检查 + if ip := net.ParseIP(host); ip != nil { + if !p.IsIPAccessAllowed(ip) { + if isPrivateIP(ip) { + return fmt.Errorf("private IP address not allowed: %s", ip.String()) + } + if p.IpFilterMode { + return fmt.Errorf("ip not in whitelist: %s", ip.String()) + } + return fmt.Errorf("ip in blacklist: %s", ip.String()) + } + return nil + } + + // 先进行域名过滤 + if !p.isDomainAllowed(host) { + if p.DomainFilterMode { + return fmt.Errorf("domain not in whitelist: %s", host) + } + return fmt.Errorf("domain in blacklist: %s", host) + } + + // 若未启用对域名应用IP过滤,则到此通过 + if !p.ApplyIPFilterForDomain { + return nil + } + + // 解析域名对应IP并检查 + ips, err := net.LookupIP(host) + if err != nil { + return fmt.Errorf("DNS resolution failed for %s: %v", host, err) + } + for _, ip := range ips { + if !p.IsIPAccessAllowed(ip) { + if isPrivateIP(ip) && !p.AllowPrivateIp { + return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String()) + } + if p.IpFilterMode { + return fmt.Errorf("ip not in whitelist: %s resolves to %s", host, ip.String()) + } + return fmt.Errorf("ip in blacklist: %s resolves to %s", host, ip.String()) + } + } + return nil +} + +// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL +func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) error { + // 如果SSRF防护被禁用,直接返回成功 + if !enableSSRFProtection { + return nil + } + + // 解析端口范围配置 + allowedPortInts, err := parsePortRanges(allowedPorts) + if err != nil { + return fmt.Errorf("request reject - invalid port configuration: %v", err) + } + + protection := &SSRFProtection{ + AllowPrivateIp: allowPrivateIp, + DomainFilterMode: domainFilterMode, + DomainList: domainList, + IpFilterMode: ipFilterMode, + IpList: ipList, + AllowedPorts: allowedPortInts, + ApplyIPFilterForDomain: applyIPFilterForDomain, + } + return protection.ValidateURL(urlStr) +} diff --git a/common/str.go b/common/str.go new file mode 100644 index 0000000000000000000000000000000000000000..6debce28b70d2a18d13b3382d3d6231ea6ca0bff --- /dev/null +++ b/common/str.go @@ -0,0 +1,237 @@ +package common + +import ( + "encoding/base64" + "encoding/json" + "math/rand" + "net/url" + "regexp" + "strconv" + "strings" + "unsafe" +) + +func GetStringIfEmpty(str string, defaultValue string) string { + if str == "" { + return defaultValue + } + return str +} + +func GetRandomString(length int) string { + //rand.Seed(time.Now().UnixNano()) + key := make([]byte, length) + for i := 0; i < length; i++ { + key[i] = keyChars[rand.Intn(len(keyChars))] + } + return string(key) +} + +func MapToJsonStr(m map[string]interface{}) string { + bytes, err := json.Marshal(m) + if err != nil { + return "" + } + return string(bytes) +} + +func StrToMap(str string) (map[string]interface{}, error) { + m := make(map[string]interface{}) + err := Unmarshal([]byte(str), &m) + if err != nil { + return nil, err + } + return m, nil +} + +func StrToJsonArray(str string) ([]interface{}, error) { + var js []interface{} + err := json.Unmarshal([]byte(str), &js) + if err != nil { + return nil, err + } + return js, nil +} + +func IsJsonArray(str string) bool { + var js []interface{} + return json.Unmarshal([]byte(str), &js) == nil +} + +func IsJsonObject(str string) bool { + var js map[string]interface{} + return json.Unmarshal([]byte(str), &js) == nil +} + +func String2Int(str string) int { + num, err := strconv.Atoi(str) + if err != nil { + return 0 + } + return num +} + +func StringsContains(strs []string, str string) bool { + for _, s := range strs { + if s == str { + return true + } + } + return false +} + +// StringToByteSlice []byte only read, panic on append +func StringToByteSlice(s string) []byte { + tmp1 := (*[2]uintptr)(unsafe.Pointer(&s)) + tmp2 := [3]uintptr{tmp1[0], tmp1[1], tmp1[1]} + return *(*[]byte)(unsafe.Pointer(&tmp2)) +} + +func EncodeBase64(str string) string { + return base64.StdEncoding.EncodeToString([]byte(str)) +} + +func GetJsonString(data any) string { + if data == nil { + return "" + } + b, _ := json.Marshal(data) + return string(b) +} + +// MaskEmail masks a user email to prevent PII leakage in logs +// Returns "***masked***" if email is empty, otherwise shows only the domain part +func MaskEmail(email string) string { + if email == "" { + return "***masked***" + } + + // Find the @ symbol + atIndex := strings.Index(email, "@") + if atIndex == -1 { + // No @ symbol found, return masked + return "***masked***" + } + + // Return only the domain part with @ symbol + return "***@" + email[atIndex+1:] +} + +// maskHostTail returns the tail parts of a domain/host that should be preserved. +// It keeps 2 parts for likely country-code TLDs (e.g., co.uk, com.cn), otherwise keeps only the TLD. +func maskHostTail(parts []string) []string { + if len(parts) < 2 { + return parts + } + lastPart := parts[len(parts)-1] + secondLastPart := parts[len(parts)-2] + if len(lastPart) == 2 && len(secondLastPart) <= 3 { + // Likely country code TLD like co.uk, com.cn + return []string{secondLastPart, lastPart} + } + return []string{lastPart} +} + +// maskHostForURL collapses subdomains and keeps only masked prefix + preserved tail. +// Example: api.openai.com -> ***.com, sub.domain.co.uk -> ***.co.uk +func maskHostForURL(host string) string { + parts := strings.Split(host, ".") + if len(parts) < 2 { + return "***" + } + tail := maskHostTail(parts) + return "***." + strings.Join(tail, ".") +} + +// maskHostForPlainDomain masks a plain domain and reflects subdomain depth with multiple ***. +// Example: openai.com -> ***.com, api.openai.com -> ***.***.com, sub.domain.co.uk -> ***.***.co.uk +func maskHostForPlainDomain(domain string) string { + parts := strings.Split(domain, ".") + if len(parts) < 2 { + return domain + } + tail := maskHostTail(parts) + numStars := len(parts) - len(tail) + if numStars < 1 { + numStars = 1 + } + stars := strings.TrimSuffix(strings.Repeat("***.", numStars), ".") + return stars + "." + strings.Join(tail, ".") +} + +// MaskSensitiveInfo masks sensitive information like URLs, IPs, and domain names in a string +// Example: +// http://example.com -> http://***.com +// https://api.test.org/v1/users/123?key=secret -> https://***.org/***/***/?key=*** +// https://sub.domain.co.uk/path/to/resource -> https://***.co.uk/***/*** +// 192.168.1.1 -> ***.***.***.*** +// openai.com -> ***.com +// www.openai.com -> ***.***.com +// api.openai.com -> ***.***.com +func MaskSensitiveInfo(str string) string { + // Mask URLs + urlPattern := regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`) + str = urlPattern.ReplaceAllStringFunc(str, func(urlStr string) string { + u, err := url.Parse(urlStr) + if err != nil { + return urlStr + } + + host := u.Host + if host == "" { + return urlStr + } + + // Mask host with unified logic + maskedHost := maskHostForURL(host) + + result := u.Scheme + "://" + maskedHost + + // Mask path + if u.Path != "" && u.Path != "/" { + pathParts := strings.Split(strings.Trim(u.Path, "/"), "/") + maskedPathParts := make([]string, len(pathParts)) + for i := range pathParts { + if pathParts[i] != "" { + maskedPathParts[i] = "***" + } + } + if len(maskedPathParts) > 0 { + result += "/" + strings.Join(maskedPathParts, "/") + } + } else if u.Path == "/" { + result += "/" + } + + // Mask query parameters + if u.RawQuery != "" { + values, err := url.ParseQuery(u.RawQuery) + if err != nil { + // If can't parse query, just mask the whole query string + result += "?***" + } else { + maskedParams := make([]string, 0, len(values)) + for key := range values { + maskedParams = append(maskedParams, key+"=***") + } + if len(maskedParams) > 0 { + result += "?" + strings.Join(maskedParams, "&") + } + } + } + + return result + }) + + // Mask domain names without protocol (like openai.com, www.openai.com) + domainPattern := regexp.MustCompile(`\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b`) + str = domainPattern.ReplaceAllStringFunc(str, func(domain string) string { + return maskHostForPlainDomain(domain) + }) + + // Mask IP addresses + ipPattern := regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`) + str = ipPattern.ReplaceAllString(str, "***.***.***.***") + + return str +} diff --git a/common/sys_log.go b/common/sys_log.go new file mode 100644 index 0000000000000000000000000000000000000000..b29adc3e63ee4195d141d252004927a41b1e26ff --- /dev/null +++ b/common/sys_log.go @@ -0,0 +1,55 @@ +package common + +import ( + "fmt" + "os" + "time" + + "github.com/gin-gonic/gin" +) + +func SysLog(s string) { + t := time.Now() + _, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s) +} + +func SysError(s string) { + t := time.Now() + _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s) +} + +func FatalLog(v ...any) { + t := time.Now() + _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v) + os.Exit(1) +} + +func LogStartupSuccess(startTime time.Time, port string) { + + duration := time.Since(startTime) + durationMs := duration.Milliseconds() + + // Get network IPs + networkIps := GetNetworkIps() + + // Print blank line for spacing + fmt.Fprintf(gin.DefaultWriter, "\n") + + // Print the main success message + fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs) + fmt.Fprintf(gin.DefaultWriter, "\n") + + // Skip fancy startup message in container environments + if !IsRunningInContainer() { + // Print local URL + fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mLocal:\033[0m http://localhost:%s/\n", port) + } + + // Print network URLs + for _, ip := range networkIps { + fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port) + } + + // Print blank line for spacing + fmt.Fprintf(gin.DefaultWriter, "\n") +} diff --git a/common/topup-ratio.go b/common/topup-ratio.go new file mode 100644 index 0000000000000000000000000000000000000000..8f03395d2f97a959c04a13ba48cdcd0a97f5bc62 --- /dev/null +++ b/common/topup-ratio.go @@ -0,0 +1,33 @@ +package common + +import ( + "encoding/json" +) + +var TopupGroupRatio = map[string]float64{ + "default": 1, + "vip": 1, + "svip": 1, +} + +func TopupGroupRatio2JSONString() string { + jsonBytes, err := json.Marshal(TopupGroupRatio) + if err != nil { + SysError("error marshalling model ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateTopupGroupRatioByJSONString(jsonStr string) error { + TopupGroupRatio = make(map[string]float64) + return json.Unmarshal([]byte(jsonStr), &TopupGroupRatio) +} + +func GetTopupGroupRatio(name string) float64 { + ratio, ok := TopupGroupRatio[name] + if !ok { + SysError("topup group ratio not found: " + name) + return 1 + } + return ratio +} diff --git a/common/totp.go b/common/totp.go new file mode 100644 index 0000000000000000000000000000000000000000..400f9d05c5b36d394f6181e85621888d6c610e7a --- /dev/null +++ b/common/totp.go @@ -0,0 +1,150 @@ +package common + +import ( + "crypto/rand" + "fmt" + "os" + "strconv" + "strings" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +const ( + // 备用码配置 + BackupCodeLength = 8 // 备用码长度 + BackupCodeCount = 4 // 生成备用码数量 + + // 限制配置 + MaxFailAttempts = 5 // 最大失败尝试次数 + LockoutDuration = 300 // 锁定时间(秒) +) + +// GenerateTOTPSecret 生成TOTP密钥和配置 +func GenerateTOTPSecret(accountName string) (*otp.Key, error) { + issuer := Get2FAIssuer() + return totp.Generate(totp.GenerateOpts{ + Issuer: issuer, + AccountName: accountName, + Period: 30, + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + }) +} + +// ValidateTOTPCode 验证TOTP验证码 +func ValidateTOTPCode(secret, code string) bool { + // 清理验证码格式 + cleanCode := strings.ReplaceAll(code, " ", "") + if len(cleanCode) != 6 { + return false + } + + // 验证验证码 + return totp.Validate(cleanCode, secret) +} + +// GenerateBackupCodes 生成备用恢复码 +func GenerateBackupCodes() ([]string, error) { + codes := make([]string, BackupCodeCount) + + for i := 0; i < BackupCodeCount; i++ { + code, err := generateRandomBackupCode() + if err != nil { + return nil, err + } + codes[i] = code + } + + return codes, nil +} + +// generateRandomBackupCode 生成单个备用码 +func generateRandomBackupCode() (string, error) { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + code := make([]byte, BackupCodeLength) + + for i := range code { + randomBytes := make([]byte, 1) + _, err := rand.Read(randomBytes) + if err != nil { + return "", err + } + code[i] = charset[int(randomBytes[0])%len(charset)] + } + + // 格式化为 XXXX-XXXX 格式 + return fmt.Sprintf("%s-%s", string(code[:4]), string(code[4:])), nil +} + +// ValidateBackupCode 验证备用码格式 +func ValidateBackupCode(code string) bool { + // 移除所有分隔符并转为大写 + cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", "")) + if len(cleanCode) != BackupCodeLength { + return false + } + + // 检查字符是否合法 + for _, char := range cleanCode { + if !((char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) { + return false + } + } + + return true +} + +// NormalizeBackupCode 标准化备用码格式 +func NormalizeBackupCode(code string) string { + cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", "")) + if len(cleanCode) == BackupCodeLength { + return fmt.Sprintf("%s-%s", cleanCode[:4], cleanCode[4:]) + } + return code +} + +// HashBackupCode 对备用码进行哈希 +func HashBackupCode(code string) (string, error) { + normalizedCode := NormalizeBackupCode(code) + return Password2Hash(normalizedCode) +} + +// Get2FAIssuer 获取2FA发行者名称 +func Get2FAIssuer() string { + return SystemName +} + +// getEnvOrDefault 获取环境变量或默认值 +func getEnvOrDefault(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} + +// ValidateNumericCode 验证数字验证码格式 +func ValidateNumericCode(code string) (string, error) { + // 移除空格 + code = strings.ReplaceAll(code, " ", "") + + if len(code) != 6 { + return "", fmt.Errorf("验证码必须是6位数字") + } + + // 检查是否为纯数字 + if _, err := strconv.Atoi(code); err != nil { + return "", fmt.Errorf("验证码只能包含数字") + } + + return code, nil +} + +// GenerateQRCodeData 生成二维码数据 +func GenerateQRCodeData(secret, username string) string { + issuer := Get2FAIssuer() + accountName := fmt.Sprintf("%s (%s)", username, issuer) + return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=6&period=30", + issuer, accountName, secret, issuer) +} diff --git a/common/utils.go b/common/utils.go new file mode 100644 index 0000000000000000000000000000000000000000..0ffa128e79ce613a45312bbdd9dc82303e5d27d3 --- /dev/null +++ b/common/utils.go @@ -0,0 +1,341 @@ +package common + +import ( + crand "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "html/template" + "io" + "log" + "math/big" + "math/rand" + "net" + "net/url" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/pkg/errors" +) + +func OpenBrowser(url string) { + var err error + + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + } + if err != nil { + log.Println(err) + } +} + +func GetIp() (ip string) { + ips, err := net.InterfaceAddrs() + if err != nil { + log.Println(err) + return ip + } + + for _, a := range ips { + if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { + if ipNet.IP.To4() != nil { + ip = ipNet.IP.String() + if strings.HasPrefix(ip, "10") { + return + } + if strings.HasPrefix(ip, "172") { + return + } + if strings.HasPrefix(ip, "192.168") { + return + } + ip = "" + } + } + } + return +} + +func GetNetworkIps() []string { + var networkIps []string + ips, err := net.InterfaceAddrs() + if err != nil { + log.Println(err) + return networkIps + } + + for _, a := range ips { + if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { + if ipNet.IP.To4() != nil { + ip := ipNet.IP.String() + // Include common private network ranges + if strings.HasPrefix(ip, "10.") || + strings.HasPrefix(ip, "172.") || + strings.HasPrefix(ip, "192.168.") { + networkIps = append(networkIps, ip) + } + } + } + } + return networkIps +} + +// IsRunningInContainer detects if the application is running inside a container +func IsRunningInContainer() bool { + // Method 1: Check for .dockerenv file (Docker containers) + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + + // Method 2: Check cgroup for container indicators + if data, err := os.ReadFile("/proc/1/cgroup"); err == nil { + content := string(data) + if strings.Contains(content, "docker") || + strings.Contains(content, "containerd") || + strings.Contains(content, "kubepods") || + strings.Contains(content, "/lxc/") { + return true + } + } + + // Method 3: Check environment variables commonly set by container runtimes + containerEnvVars := []string{ + "KUBERNETES_SERVICE_HOST", + "DOCKER_CONTAINER", + "container", + } + + for _, envVar := range containerEnvVars { + if os.Getenv(envVar) != "" { + return true + } + } + + // Method 4: Check if init process is not the traditional init + if data, err := os.ReadFile("/proc/1/comm"); err == nil { + comm := strings.TrimSpace(string(data)) + // In containers, process 1 is often not "init" or "systemd" + if comm != "init" && comm != "systemd" { + // Additional check: if it's a common container entrypoint + if strings.Contains(comm, "docker") || + strings.Contains(comm, "containerd") || + strings.Contains(comm, "runc") { + return true + } + } + } + + return false +} + +var sizeKB = 1024 +var sizeMB = sizeKB * 1024 +var sizeGB = sizeMB * 1024 + +func Bytes2Size(num int64) string { + numStr := "" + unit := "B" + if num/int64(sizeGB) > 1 { + numStr = fmt.Sprintf("%.2f", float64(num)/float64(sizeGB)) + unit = "GB" + } else if num/int64(sizeMB) > 1 { + numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeMB))) + unit = "MB" + } else if num/int64(sizeKB) > 1 { + numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeKB))) + unit = "KB" + } else { + numStr = fmt.Sprintf("%d", num) + } + return numStr + " " + unit +} + +func Seconds2Time(num int) (time string) { + if num/31104000 > 0 { + time += strconv.Itoa(num/31104000) + " 年 " + num %= 31104000 + } + if num/2592000 > 0 { + time += strconv.Itoa(num/2592000) + " 个月 " + num %= 2592000 + } + if num/86400 > 0 { + time += strconv.Itoa(num/86400) + " 天 " + num %= 86400 + } + if num/3600 > 0 { + time += strconv.Itoa(num/3600) + " 小时 " + num %= 3600 + } + if num/60 > 0 { + time += strconv.Itoa(num/60) + " 分钟 " + num %= 60 + } + time += strconv.Itoa(num) + " 秒" + return +} + +func Interface2String(inter interface{}) string { + switch inter.(type) { + case string: + return inter.(string) + case int: + return fmt.Sprintf("%d", inter.(int)) + case float64: + return fmt.Sprintf("%f", inter.(float64)) + case bool: + if inter.(bool) { + return "true" + } else { + return "false" + } + case nil: + return "" + } + return fmt.Sprintf("%v", inter) +} + +func UnescapeHTML(x string) interface{} { + return template.HTML(x) +} + +func IntMax(a int, b int) int { + if a >= b { + return a + } else { + return b + } +} + +func IsIP(s string) bool { + ip := net.ParseIP(s) + return ip != nil +} + +func GetUUID() string { + code := uuid.New().String() + code = strings.Replace(code, "-", "", -1) + return code +} + +const keyChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func GenerateRandomCharsKey(length int) (string, error) { + b := make([]byte, length) + maxI := big.NewInt(int64(len(keyChars))) + + for i := range b { + n, err := crand.Int(crand.Reader, maxI) + if err != nil { + return "", err + } + b[i] = keyChars[n.Int64()] + } + + return string(b), nil +} + +func GenerateRandomKey(length int) (string, error) { + bytes := make([]byte, length*3/4) // 对于48位的输出,这里应该是36 + if _, err := crand.Read(bytes); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(bytes), nil +} + +func GenerateKey() (string, error) { + //rand.Seed(time.Now().UnixNano()) + return GenerateRandomCharsKey(48) +} + +func GetRandomInt(max int) int { + //rand.Seed(time.Now().UnixNano()) + return rand.Intn(max) +} + +func GetTimestamp() int64 { + return time.Now().Unix() +} + +func GetTimeString() string { + now := time.Now() + return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9) +} + +func Max(a int, b int) int { + if a >= b { + return a + } else { + return b + } +} + +func MessageWithRequestId(message string, id string) string { + return fmt.Sprintf("%s (request id: %s)", message, id) +} + +func RandomSleep() { + // Sleep for 0-3000 ms + time.Sleep(time.Duration(rand.Intn(3000)) * time.Millisecond) +} + +func GetPointer[T any](v T) *T { + return &v +} + +func Any2Type[T any](data any) (T, error) { + var zero T + bytes, err := json.Marshal(data) + if err != nil { + return zero, err + } + var res T + err = json.Unmarshal(bytes, &res) + if err != nil { + return zero, err + } + return res, nil +} + +// SaveTmpFile saves data to a temporary file. The filename would be apppended with a random string. +func SaveTmpFile(filename string, data io.Reader) (string, error) { + f, err := os.CreateTemp(os.TempDir(), filename) + if err != nil { + return "", errors.Wrapf(err, "failed to create temporary file %s", filename) + } + defer f.Close() + + _, err = io.Copy(f, data) + if err != nil { + return "", errors.Wrapf(err, "failed to copy data to temporary file %s", filename) + } + + return f.Name(), nil +} + +// BuildURL concatenates base and endpoint, returns the complete url string +func BuildURL(base string, endpoint string) string { + u, err := url.Parse(base) + if err != nil { + return base + endpoint + } + end := endpoint + if end == "" { + end = "/" + } + ref, err := url.Parse(end) + if err != nil { + return base + endpoint + } + return u.ResolveReference(ref).String() +} diff --git a/common/validate.go b/common/validate.go new file mode 100644 index 0000000000000000000000000000000000000000..b3c78591078bd4a8ab321f9368fcc0721d8f60f0 --- /dev/null +++ b/common/validate.go @@ -0,0 +1,9 @@ +package common + +import "github.com/go-playground/validator/v10" + +var Validate *validator.Validate + +func init() { + Validate = validator.New() +} diff --git a/common/verification.go b/common/verification.go new file mode 100644 index 0000000000000000000000000000000000000000..41fd3c943e7edc52d43d4fb417ff9e8504bddf9c --- /dev/null +++ b/common/verification.go @@ -0,0 +1,78 @@ +package common + +import ( + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +type verificationValue struct { + code string + time time.Time +} + +const ( + EmailVerificationPurpose = "v" + PasswordResetPurpose = "r" +) + +var verificationMutex sync.Mutex +var verificationMap map[string]verificationValue +var verificationMapMaxSize = 10 +var VerificationValidMinutes = 10 + +func GenerateVerificationCode(length int) string { + code := uuid.New().String() + code = strings.Replace(code, "-", "", -1) + if length == 0 { + return code + } + return code[:length] +} + +func RegisterVerificationCodeWithKey(key string, code string, purpose string) { + verificationMutex.Lock() + defer verificationMutex.Unlock() + verificationMap[purpose+key] = verificationValue{ + code: code, + time: time.Now(), + } + if len(verificationMap) > verificationMapMaxSize { + removeExpiredPairs() + } +} + +func VerifyCodeWithKey(key string, code string, purpose string) bool { + verificationMutex.Lock() + defer verificationMutex.Unlock() + value, okay := verificationMap[purpose+key] + now := time.Now() + if !okay || int(now.Sub(value.time).Seconds()) >= VerificationValidMinutes*60 { + return false + } + return code == value.code +} + +func DeleteKey(key string, purpose string) { + verificationMutex.Lock() + defer verificationMutex.Unlock() + delete(verificationMap, purpose+key) +} + +// no lock inside, so the caller must lock the verificationMap before calling! +func removeExpiredPairs() { + now := time.Now() + for key := range verificationMap { + if int(now.Sub(verificationMap[key].time).Seconds()) >= VerificationValidMinutes*60 { + delete(verificationMap, key) + } + } +} + +func init() { + verificationMutex.Lock() + defer verificationMutex.Unlock() + verificationMap = make(map[string]verificationValue) +} diff --git a/constant/README.md b/constant/README.md new file mode 100644 index 0000000000000000000000000000000000000000..12a9ffad37daeb88245bee86582cd3654d763837 --- /dev/null +++ b/constant/README.md @@ -0,0 +1,26 @@ +# constant 包 (`/constant`) + +该目录仅用于放置全局可复用的**常量定义**,不包含任何业务逻辑或依赖关系。 + +## 当前文件 + +| 文件 | 说明 | +|----------------------|---------------------------------------------------------------------| +| `azure.go` | 定义与 Azure 相关的全局常量,如 `AzureNoRemoveDotTime`(控制删除 `.` 的截止时间)。 | +| `cache_key.go` | 缓存键格式字符串及 Token 相关字段常量,统一缓存命名规则。 | +| `channel_setting.go` | Channel 级别的设置键,如 `proxy`、`force_format` 等。 | +| `context_key.go` | 定义 `ContextKey` 类型以及在整个项目中使用的上下文键常量(请求时间、Token/Channel/User 相关信息等)。 | +| `env.go` | 环境配置相关的全局变量,在启动阶段根据配置文件或环境变量注入。 | +| `finish_reason.go` | OpenAI/GPT 请求返回的 `finish_reason` 字符串常量集合。 | +| `midjourney.go` | Midjourney 相关错误码及动作(Action)常量与模型到动作的映射表。 | +| `setup.go` | 标识项目是否已完成初始化安装 (`Setup` 布尔值)。 | +| `task.go` | 各种任务(Task)平台、动作常量及模型与动作映射表,如 Suno、Midjourney 等。 | +| `user_setting.go` | 用户设置相关键常量以及通知类型(Email/Webhook)等。 | + +## 使用约定 + +1. `constant` 包**只能被其他包引用**(import),**禁止在此包中引用项目内的其他自定义包**。如确有需要,仅允许引用 **Go 标准库**。 +2. 不允许在此目录内编写任何与业务流程、数据库操作、第三方服务调用等相关的逻辑代码。 +3. 新增类型时,请保持命名语义清晰,并在本 README 的 **当前文件** 表格中补充说明,确保团队成员能够快速了解其用途。 + +> ⚠️ 违反以上约定将导致包之间产生不必要的耦合,影响代码可维护性与可测试性。请在提交代码前自行检查。 \ No newline at end of file diff --git a/constant/api_type.go b/constant/api_type.go new file mode 100644 index 0000000000000000000000000000000000000000..32b48bcd296aa4b515a21a5a62e26547af578796 --- /dev/null +++ b/constant/api_type.go @@ -0,0 +1,39 @@ +package constant + +const ( + APITypeOpenAI = iota + APITypeAnthropic + APITypePaLM + APITypeBaidu + APITypeZhipu + APITypeAli + APITypeXunfei + APITypeAIProxyLibrary + APITypeTencent + APITypeGemini + APITypeZhipuV4 + APITypeOllama + APITypePerplexity + APITypeAws + APITypeCohere + APITypeDify + APITypeJina + APITypeCloudflare + APITypeSiliconFlow + APITypeVertexAi + APITypeMistral + APITypeDeepSeek + APITypeMokaAI + APITypeVolcEngine + APITypeBaiduV2 + APITypeOpenRouter + APITypeXinference + APITypeXai + APITypeCoze + APITypeJimeng + APITypeMoonshot + APITypeSubmodel + APITypeMiniMax + APITypeReplicate + APITypeDummy // this one is only for count, do not add any channel after this +) diff --git a/constant/azure.go b/constant/azure.go new file mode 100644 index 0000000000000000000000000000000000000000..d84040ce78b368cd1dc92f17e27c64f05305d4f6 --- /dev/null +++ b/constant/azure.go @@ -0,0 +1,5 @@ +package constant + +import "time" + +var AzureNoRemoveDotTime = time.Date(2025, time.May, 10, 0, 0, 0, 0, time.UTC).Unix() diff --git a/constant/cache_key.go b/constant/cache_key.go new file mode 100644 index 0000000000000000000000000000000000000000..0601396a4311e77f5b29c124dbe623dbf4ce03cb --- /dev/null +++ b/constant/cache_key.go @@ -0,0 +1,14 @@ +package constant + +// Cache keys +const ( + UserGroupKeyFmt = "user_group:%d" + UserQuotaKeyFmt = "user_quota:%d" + UserEnabledKeyFmt = "user_enabled:%d" + UserUsernameKeyFmt = "user_name:%d" +) + +const ( + TokenFiledRemainQuota = "RemainQuota" + TokenFieldGroup = "Group" +) diff --git a/constant/channel.go b/constant/channel.go new file mode 100644 index 0000000000000000000000000000000000000000..023faea3ddc69a466904e63224859f17a34d26c8 --- /dev/null +++ b/constant/channel.go @@ -0,0 +1,182 @@ +package constant + +const ( + ChannelTypeUnknown = 0 + ChannelTypeOpenAI = 1 + ChannelTypeMidjourney = 2 + ChannelTypeAzure = 3 + ChannelTypeOllama = 4 + ChannelTypeMidjourneyPlus = 5 + ChannelTypeOpenAIMax = 6 + ChannelTypeOhMyGPT = 7 + ChannelTypeCustom = 8 + ChannelTypeAILS = 9 + ChannelTypeAIProxy = 10 + ChannelTypePaLM = 11 + ChannelTypeAPI2GPT = 12 + ChannelTypeAIGC2D = 13 + ChannelTypeAnthropic = 14 + ChannelTypeBaidu = 15 + ChannelTypeZhipu = 16 + ChannelTypeAli = 17 + ChannelTypeXunfei = 18 + ChannelType360 = 19 + ChannelTypeOpenRouter = 20 + ChannelTypeAIProxyLibrary = 21 + ChannelTypeFastGPT = 22 + ChannelTypeTencent = 23 + ChannelTypeGemini = 24 + ChannelTypeMoonshot = 25 + ChannelTypeZhipu_v4 = 26 + ChannelTypePerplexity = 27 + ChannelTypeLingYiWanWu = 31 + ChannelTypeAws = 33 + ChannelTypeCohere = 34 + ChannelTypeMiniMax = 35 + ChannelTypeSunoAPI = 36 + ChannelTypeDify = 37 + ChannelTypeJina = 38 + ChannelCloudflare = 39 + ChannelTypeSiliconFlow = 40 + ChannelTypeVertexAi = 41 + ChannelTypeMistral = 42 + ChannelTypeDeepSeek = 43 + ChannelTypeMokaAI = 44 + ChannelTypeVolcEngine = 45 + ChannelTypeBaiduV2 = 46 + ChannelTypeXinference = 47 + ChannelTypeXai = 48 + ChannelTypeCoze = 49 + ChannelTypeKling = 50 + ChannelTypeJimeng = 51 + ChannelTypeVidu = 52 + ChannelTypeSubmodel = 53 + ChannelTypeDoubaoVideo = 54 + ChannelTypeSora = 55 + ChannelTypeReplicate = 56 + ChannelTypeDummy // this one is only for count, do not add any channel after this + +) + +var ChannelBaseURLs = []string{ + "", // 0 + "https://api.openai.com", // 1 + "https://oa.api2d.net", // 2 + "", // 3 + "http://localhost:11434", // 4 + "https://api.openai-sb.com", // 5 + "https://api.openaimax.com", // 6 + "https://api.ohmygpt.com", // 7 + "", // 8 + "https://api.caipacity.com", // 9 + "https://api.aiproxy.io", // 10 + "", // 11 + "https://api.api2gpt.com", // 12 + "https://api.aigc2d.com", // 13 + "https://api.anthropic.com", // 14 + "https://aip.baidubce.com", // 15 + "https://open.bigmodel.cn", // 16 + "https://dashscope.aliyuncs.com", // 17 + "", // 18 + "https://api.360.cn", // 19 + "https://openrouter.ai/api", // 20 + "https://api.aiproxy.io", // 21 + "https://fastgpt.run/api/openapi", // 22 + "https://hunyuan.tencentcloudapi.com", //23 + "https://generativelanguage.googleapis.com", //24 + "https://api.moonshot.cn", //25 + "https://open.bigmodel.cn", //26 + "https://api.perplexity.ai", //27 + "", //28 + "", //29 + "", //30 + "https://api.lingyiwanwu.com", //31 + "", //32 + "", //33 + "https://api.cohere.ai", //34 + "https://api.minimax.chat", //35 + "", //36 + "https://api.dify.ai", //37 + "https://api.jina.ai", //38 + "https://api.cloudflare.com", //39 + "https://api.siliconflow.cn", //40 + "", //41 + "https://api.mistral.ai", //42 + "https://api.deepseek.com", //43 + "https://api.moka.ai", //44 + "https://ark.cn-beijing.volces.com", //45 + "https://qianfan.baidubce.com", //46 + "", //47 + "https://api.x.ai", //48 + "https://api.coze.cn", //49 + "https://api.klingai.com", //50 + "https://visual.volcengineapi.com", //51 + "https://api.vidu.cn", //52 + "https://llm.submodel.ai", //53 + "https://ark.cn-beijing.volces.com", //54 + "https://api.openai.com", //55 + "https://api.replicate.com", //56 +} + +var ChannelTypeNames = map[int]string{ + ChannelTypeUnknown: "Unknown", + ChannelTypeOpenAI: "OpenAI", + ChannelTypeMidjourney: "Midjourney", + ChannelTypeAzure: "Azure", + ChannelTypeOllama: "Ollama", + ChannelTypeMidjourneyPlus: "MidjourneyPlus", + ChannelTypeOpenAIMax: "OpenAIMax", + ChannelTypeOhMyGPT: "OhMyGPT", + ChannelTypeCustom: "Custom", + ChannelTypeAILS: "AILS", + ChannelTypeAIProxy: "AIProxy", + ChannelTypePaLM: "PaLM", + ChannelTypeAPI2GPT: "API2GPT", + ChannelTypeAIGC2D: "AIGC2D", + ChannelTypeAnthropic: "Anthropic", + ChannelTypeBaidu: "Baidu", + ChannelTypeZhipu: "Zhipu", + ChannelTypeAli: "Ali", + ChannelTypeXunfei: "Xunfei", + ChannelType360: "360", + ChannelTypeOpenRouter: "OpenRouter", + ChannelTypeAIProxyLibrary: "AIProxyLibrary", + ChannelTypeFastGPT: "FastGPT", + ChannelTypeTencent: "Tencent", + ChannelTypeGemini: "Gemini", + ChannelTypeMoonshot: "Moonshot", + ChannelTypeZhipu_v4: "ZhipuV4", + ChannelTypePerplexity: "Perplexity", + ChannelTypeLingYiWanWu: "LingYiWanWu", + ChannelTypeAws: "AWS", + ChannelTypeCohere: "Cohere", + ChannelTypeMiniMax: "MiniMax", + ChannelTypeSunoAPI: "SunoAPI", + ChannelTypeDify: "Dify", + ChannelTypeJina: "Jina", + ChannelCloudflare: "Cloudflare", + ChannelTypeSiliconFlow: "SiliconFlow", + ChannelTypeVertexAi: "VertexAI", + ChannelTypeMistral: "Mistral", + ChannelTypeDeepSeek: "DeepSeek", + ChannelTypeMokaAI: "MokaAI", + ChannelTypeVolcEngine: "VolcEngine", + ChannelTypeBaiduV2: "BaiduV2", + ChannelTypeXinference: "Xinference", + ChannelTypeXai: "xAI", + ChannelTypeCoze: "Coze", + ChannelTypeKling: "Kling", + ChannelTypeJimeng: "Jimeng", + ChannelTypeVidu: "Vidu", + ChannelTypeSubmodel: "Submodel", + ChannelTypeDoubaoVideo: "DoubaoVideo", + ChannelTypeSora: "Sora", + ChannelTypeReplicate: "Replicate", +} + +func GetChannelTypeName(channelType int) string { + if name, ok := ChannelTypeNames[channelType]; ok { + return name + } + return "Unknown" +} diff --git a/constant/context_key.go b/constant/context_key.go new file mode 100644 index 0000000000000000000000000000000000000000..e5461d43c03f071c8d5ad691a201b12ed23487f1 --- /dev/null +++ b/constant/context_key.go @@ -0,0 +1,52 @@ +package constant + +type ContextKey string + +const ( + ContextKeyTokenCountMeta ContextKey = "token_count_meta" + ContextKeyPromptTokens ContextKey = "prompt_tokens" + + ContextKeyOriginalModel ContextKey = "original_model" + ContextKeyRequestStartTime ContextKey = "request_start_time" + + /* token related keys */ + ContextKeyTokenUnlimited ContextKey = "token_unlimited_quota" + ContextKeyTokenKey ContextKey = "token_key" + ContextKeyTokenId ContextKey = "token_id" + ContextKeyTokenGroup ContextKey = "token_group" + ContextKeyTokenSpecificChannelId ContextKey = "specific_channel_id" + ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled" + ContextKeyTokenModelLimit ContextKey = "token_model_limit" + + /* channel related keys */ + ContextKeyChannelId ContextKey = "channel_id" + ContextKeyChannelName ContextKey = "channel_name" + ContextKeyChannelCreateTime ContextKey = "channel_create_time" + ContextKeyChannelBaseUrl ContextKey = "base_url" + ContextKeyChannelType ContextKey = "channel_type" + ContextKeyChannelSetting ContextKey = "channel_setting" + ContextKeyChannelOtherSetting ContextKey = "channel_other_setting" + ContextKeyChannelParamOverride ContextKey = "param_override" + ContextKeyChannelHeaderOverride ContextKey = "header_override" + ContextKeyChannelOrganization ContextKey = "channel_organization" + ContextKeyChannelAutoBan ContextKey = "auto_ban" + ContextKeyChannelModelMapping ContextKey = "model_mapping" + ContextKeyChannelStatusCodeMapping ContextKey = "status_code_mapping" + ContextKeyChannelIsMultiKey ContextKey = "channel_is_multi_key" + ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index" + ContextKeyChannelKey ContextKey = "channel_key" + + /* user related keys */ + ContextKeyUserId ContextKey = "id" + ContextKeyUserSetting ContextKey = "user_setting" + ContextKeyUserQuota ContextKey = "user_quota" + ContextKeyUserStatus ContextKey = "user_status" + ContextKeyUserEmail ContextKey = "user_email" + ContextKeyUserGroup ContextKey = "user_group" + ContextKeyUsingGroup ContextKey = "group" + ContextKeyUserName ContextKey = "username" + + ContextKeyLocalCountTokens ContextKey = "local_count_tokens" + + ContextKeySystemPromptOverride ContextKey = "system_prompt_override" +) diff --git a/constant/endpoint_type.go b/constant/endpoint_type.go new file mode 100644 index 0000000000000000000000000000000000000000..29c0d31e37d78e679ad282f63b3cd65b2cddcf68 --- /dev/null +++ b/constant/endpoint_type.go @@ -0,0 +1,18 @@ +package constant + +type EndpointType string + +const ( + EndpointTypeOpenAI EndpointType = "openai" + EndpointTypeOpenAIResponse EndpointType = "openai-response" + EndpointTypeAnthropic EndpointType = "anthropic" + EndpointTypeGemini EndpointType = "gemini" + EndpointTypeJinaRerank EndpointType = "jina-rerank" + EndpointTypeImageGeneration EndpointType = "image-generation" + EndpointTypeEmbeddings EndpointType = "embeddings" + EndpointTypeOpenAIVideo EndpointType = "openai-video" + //EndpointTypeMidjourney EndpointType = "midjourney-proxy" + //EndpointTypeSuno EndpointType = "suno-proxy" + //EndpointTypeKling EndpointType = "kling" + //EndpointTypeJimeng EndpointType = "jimeng" +) diff --git a/constant/env.go b/constant/env.go new file mode 100644 index 0000000000000000000000000000000000000000..ade835c019448d1b93b53a3612953627c195b5d2 --- /dev/null +++ b/constant/env.go @@ -0,0 +1,19 @@ +package constant + +var StreamingTimeout int +var DifyDebug bool +var MaxFileDownloadMB int +var ForceStreamOption bool +var CountToken bool +var GetMediaToken bool +var GetMediaTokenNotStream bool +var UpdateTask bool +var AzureDefaultAPIVersion string +var GeminiVisionMaxImageNum int +var NotifyLimitCount int +var NotificationLimitDurationMinute int +var GenerateDefaultToken bool +var ErrorLogEnabled bool + +// temporary variable for sora patch, will be removed in future +var TaskPricePatches []string diff --git a/constant/finish_reason.go b/constant/finish_reason.go new file mode 100644 index 0000000000000000000000000000000000000000..5a752a5f48edd30037ea5f62186e292415348e56 --- /dev/null +++ b/constant/finish_reason.go @@ -0,0 +1,9 @@ +package constant + +var ( + FinishReasonStop = "stop" + FinishReasonToolCalls = "tool_calls" + FinishReasonLength = "length" + FinishReasonFunctionCall = "function_call" + FinishReasonContentFilter = "content_filter" +) diff --git a/constant/midjourney.go b/constant/midjourney.go new file mode 100644 index 0000000000000000000000000000000000000000..5934be2f51638ed4775cc2cf7d23c277163440f9 --- /dev/null +++ b/constant/midjourney.go @@ -0,0 +1,48 @@ +package constant + +const ( + MjErrorUnknown = 5 + MjRequestError = 4 +) + +const ( + MjActionImagine = "IMAGINE" + MjActionDescribe = "DESCRIBE" + MjActionBlend = "BLEND" + MjActionUpscale = "UPSCALE" + MjActionVariation = "VARIATION" + MjActionReRoll = "REROLL" + MjActionInPaint = "INPAINT" + MjActionModal = "MODAL" + MjActionZoom = "ZOOM" + MjActionCustomZoom = "CUSTOM_ZOOM" + MjActionShorten = "SHORTEN" + MjActionHighVariation = "HIGH_VARIATION" + MjActionLowVariation = "LOW_VARIATION" + MjActionPan = "PAN" + MjActionSwapFace = "SWAP_FACE" + MjActionUpload = "UPLOAD" + MjActionVideo = "VIDEO" + MjActionEdits = "EDITS" +) + +var MidjourneyModel2Action = map[string]string{ + "mj_imagine": MjActionImagine, + "mj_describe": MjActionDescribe, + "mj_blend": MjActionBlend, + "mj_upscale": MjActionUpscale, + "mj_variation": MjActionVariation, + "mj_reroll": MjActionReRoll, + "mj_modal": MjActionModal, + "mj_inpaint": MjActionInPaint, + "mj_zoom": MjActionZoom, + "mj_custom_zoom": MjActionCustomZoom, + "mj_shorten": MjActionShorten, + "mj_high_variation": MjActionHighVariation, + "mj_low_variation": MjActionLowVariation, + "mj_pan": MjActionPan, + "swap_face": MjActionSwapFace, + "mj_upload": MjActionUpload, + "mj_video": MjActionVideo, + "mj_edits": MjActionEdits, +} diff --git a/constant/multi_key_mode.go b/constant/multi_key_mode.go new file mode 100644 index 0000000000000000000000000000000000000000..cd0cdbff5fcf6116a0c1db88a208cfd52d3f51cc --- /dev/null +++ b/constant/multi_key_mode.go @@ -0,0 +1,8 @@ +package constant + +type MultiKeyMode string + +const ( + MultiKeyModeRandom MultiKeyMode = "random" // 随机 + MultiKeyModePolling MultiKeyMode = "polling" // 轮询 +) diff --git a/constant/setup.go b/constant/setup.go new file mode 100644 index 0000000000000000000000000000000000000000..26ecc88302781b9421f77efd6a2b0b2c5fbb0c11 --- /dev/null +++ b/constant/setup.go @@ -0,0 +1,3 @@ +package constant + +var Setup = false diff --git a/constant/task.go b/constant/task.go new file mode 100644 index 0000000000000000000000000000000000000000..e174fd60e04da0b668b8c577c8930eb9923aabfa --- /dev/null +++ b/constant/task.go @@ -0,0 +1,23 @@ +package constant + +type TaskPlatform string + +const ( + TaskPlatformSuno TaskPlatform = "suno" + TaskPlatformMidjourney = "mj" +) + +const ( + SunoActionMusic = "MUSIC" + SunoActionLyrics = "LYRICS" + + TaskActionGenerate = "generate" + TaskActionTextGenerate = "textGenerate" + TaskActionFirstTailGenerate = "firstTailGenerate" + TaskActionReferenceGenerate = "referenceGenerate" +) + +var SunoModel2Action = map[string]string{ + "suno_music": SunoActionMusic, + "suno_lyrics": SunoActionLyrics, +} diff --git a/controller/billing.go b/controller/billing.go new file mode 100644 index 0000000000000000000000000000000000000000..1c92a25070ed54cee7c69925d7598ef73af8a600 --- /dev/null +++ b/controller/billing.go @@ -0,0 +1,108 @@ +package controller + +import ( + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/gin-gonic/gin" +) + +func GetSubscription(c *gin.Context) { + var remainQuota int + var usedQuota int + var err error + var token *model.Token + var expiredTime int64 + if common.DisplayTokenStatEnabled { + tokenId := c.GetInt("token_id") + token, err = model.GetTokenById(tokenId) + expiredTime = token.ExpiredTime + remainQuota = token.RemainQuota + usedQuota = token.UsedQuota + } else { + userId := c.GetInt("id") + remainQuota, err = model.GetUserQuota(userId, false) + usedQuota, err = model.GetUserUsedQuota(userId) + } + if expiredTime <= 0 { + expiredTime = 0 + } + if err != nil { + openAIError := dto.OpenAIError{ + Message: err.Error(), + Type: "upstream_error", + } + c.JSON(200, gin.H{ + "error": openAIError, + }) + return + } + quota := remainQuota + usedQuota + amount := float64(quota) + // OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值: + // 我们将其解释为以“站点展示类型”为准: + // - USD: 直接除以 QuotaPerUnit + // - CNY: 先转 USD 再乘汇率 + // - TOKENS: 直接使用 tokens 数量 + switch operation_setting.GetQuotaDisplayType() { + case operation_setting.QuotaDisplayTypeCNY: + amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate + case operation_setting.QuotaDisplayTypeTokens: + // amount 保持 tokens 数值 + default: + amount = amount / common.QuotaPerUnit + } + if token != nil && token.UnlimitedQuota { + amount = 100000000 + } + subscription := OpenAISubscriptionResponse{ + Object: "billing_subscription", + HasPaymentMethod: true, + SoftLimitUSD: amount, + HardLimitUSD: amount, + SystemHardLimitUSD: amount, + AccessUntil: expiredTime, + } + c.JSON(200, subscription) + return +} + +func GetUsage(c *gin.Context) { + var quota int + var err error + var token *model.Token + if common.DisplayTokenStatEnabled { + tokenId := c.GetInt("token_id") + token, err = model.GetTokenById(tokenId) + quota = token.UsedQuota + } else { + userId := c.GetInt("id") + quota, err = model.GetUserUsedQuota(userId) + } + if err != nil { + openAIError := dto.OpenAIError{ + Message: err.Error(), + Type: "new_api_error", + } + c.JSON(200, gin.H{ + "error": openAIError, + }) + return + } + amount := float64(quota) + switch operation_setting.GetQuotaDisplayType() { + case operation_setting.QuotaDisplayTypeCNY: + amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate + case operation_setting.QuotaDisplayTypeTokens: + // tokens 保持原值 + default: + amount = amount / common.QuotaPerUnit + } + usage := OpenAIUsageResponse{ + Object: "list", + TotalUsage: amount * 100, + } + c.JSON(200, usage) + return +} diff --git a/controller/channel-billing.go b/controller/channel-billing.go new file mode 100644 index 0000000000000000000000000000000000000000..751ee3600ac9e8a7f9f1f4a1f06afd5f51129db1 --- /dev/null +++ b/controller/channel-billing.go @@ -0,0 +1,505 @@ +package controller + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/shopspring/decimal" + + "github.com/gin-gonic/gin" +) + +// https://github.com/songquanpeng/one-api/issues/79 + +type OpenAISubscriptionResponse struct { + Object string `json:"object"` + HasPaymentMethod bool `json:"has_payment_method"` + SoftLimitUSD float64 `json:"soft_limit_usd"` + HardLimitUSD float64 `json:"hard_limit_usd"` + SystemHardLimitUSD float64 `json:"system_hard_limit_usd"` + AccessUntil int64 `json:"access_until"` +} + +type OpenAIUsageDailyCost struct { + Timestamp float64 `json:"timestamp"` + LineItems []struct { + Name string `json:"name"` + Cost float64 `json:"cost"` + } +} + +type OpenAICreditGrants struct { + Object string `json:"object"` + TotalGranted float64 `json:"total_granted"` + TotalUsed float64 `json:"total_used"` + TotalAvailable float64 `json:"total_available"` +} + +type OpenAIUsageResponse struct { + Object string `json:"object"` + //DailyCosts []OpenAIUsageDailyCost `json:"daily_costs"` + TotalUsage float64 `json:"total_usage"` // unit: 0.01 dollar +} + +type OpenAISBUsageResponse struct { + Msg string `json:"msg"` + Data *struct { + Credit string `json:"credit"` + } `json:"data"` +} + +type AIProxyUserOverviewResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + ErrorCode int `json:"error_code"` + Data struct { + TotalPoints float64 `json:"totalPoints"` + } `json:"data"` +} + +type API2GPTUsageResponse struct { + Object string `json:"object"` + TotalGranted float64 `json:"total_granted"` + TotalUsed float64 `json:"total_used"` + TotalRemaining float64 `json:"total_remaining"` +} + +type APGC2DGPTUsageResponse struct { + //Grants interface{} `json:"grants"` + Object string `json:"object"` + TotalAvailable float64 `json:"total_available"` + TotalGranted float64 `json:"total_granted"` + TotalUsed float64 `json:"total_used"` +} + +type SiliconFlowUsageResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Status bool `json:"status"` + Data struct { + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + Email string `json:"email"` + IsAdmin bool `json:"isAdmin"` + Balance string `json:"balance"` + Status string `json:"status"` + Introduction string `json:"introduction"` + Role string `json:"role"` + ChargeBalance string `json:"chargeBalance"` + TotalBalance string `json:"totalBalance"` + Category string `json:"category"` + } `json:"data"` +} + +type DeepSeekUsageResponse struct { + IsAvailable bool `json:"is_available"` + BalanceInfos []struct { + Currency string `json:"currency"` + TotalBalance string `json:"total_balance"` + GrantedBalance string `json:"granted_balance"` + ToppedUpBalance string `json:"topped_up_balance"` + } `json:"balance_infos"` +} + +type OpenRouterCreditResponse struct { + Data struct { + TotalCredits float64 `json:"total_credits"` + TotalUsage float64 `json:"total_usage"` + } `json:"data"` +} + +// GetAuthHeader get auth header +func GetAuthHeader(token string) http.Header { + h := http.Header{} + h.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + return h +} + +// GetClaudeAuthHeader get claude auth header +func GetClaudeAuthHeader(token string) http.Header { + h := http.Header{} + h.Add("x-api-key", token) + h.Add("anthropic-version", "2023-06-01") + return h +} + +func GetResponseBody(method, url string, channel *model.Channel, headers http.Header) ([]byte, error) { + req, err := http.NewRequest(method, url, nil) + if err != nil { + return nil, err + } + for k := range headers { + req.Header.Add(k, headers.Get(k)) + } + client, err := service.NewProxyHttpClient(channel.GetSetting().Proxy) + if err != nil { + return nil, err + } + res, err := client.Do(req) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status code: %d", res.StatusCode) + } + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + err = res.Body.Close() + if err != nil { + return nil, err + } + return body, nil +} + +func updateChannelCloseAIBalance(channel *model.Channel) (float64, error) { + url := fmt.Sprintf("%s/dashboard/billing/credit_grants", channel.GetBaseURL()) + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + + if err != nil { + return 0, err + } + response := OpenAICreditGrants{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + channel.UpdateBalance(response.TotalAvailable) + return response.TotalAvailable, nil +} + +func updateChannelOpenAISBBalance(channel *model.Channel) (float64, error) { + url := fmt.Sprintf("https://api.openai-sb.com/sb-api/user/status?api_key=%s", channel.Key) + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := OpenAISBUsageResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + if response.Data == nil { + return 0, errors.New(response.Msg) + } + balance, err := strconv.ParseFloat(response.Data.Credit, 64) + if err != nil { + return 0, err + } + channel.UpdateBalance(balance) + return balance, nil +} + +func updateChannelAIProxyBalance(channel *model.Channel) (float64, error) { + url := "https://aiproxy.io/api/report/getUserOverview" + headers := http.Header{} + headers.Add("Api-Key", channel.Key) + body, err := GetResponseBody("GET", url, channel, headers) + if err != nil { + return 0, err + } + response := AIProxyUserOverviewResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + if !response.Success { + return 0, fmt.Errorf("code: %d, message: %s", response.ErrorCode, response.Message) + } + channel.UpdateBalance(response.Data.TotalPoints) + return response.Data.TotalPoints, nil +} + +func updateChannelAPI2GPTBalance(channel *model.Channel) (float64, error) { + url := "https://api.api2gpt.com/dashboard/billing/credit_grants" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + + if err != nil { + return 0, err + } + response := API2GPTUsageResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + channel.UpdateBalance(response.TotalRemaining) + return response.TotalRemaining, nil +} + +func updateChannelSiliconFlowBalance(channel *model.Channel) (float64, error) { + url := "https://api.siliconflow.cn/v1/user/info" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := SiliconFlowUsageResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + if response.Code != 20000 { + return 0, fmt.Errorf("code: %d, message: %s", response.Code, response.Message) + } + balance, err := strconv.ParseFloat(response.Data.TotalBalance, 64) + if err != nil { + return 0, err + } + channel.UpdateBalance(balance) + return balance, nil +} + +func updateChannelDeepSeekBalance(channel *model.Channel) (float64, error) { + url := "https://api.deepseek.com/user/balance" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := DeepSeekUsageResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + index := -1 + for i, balanceInfo := range response.BalanceInfos { + if balanceInfo.Currency == "CNY" { + index = i + break + } + } + if index == -1 { + return 0, errors.New("currency CNY not found") + } + balance, err := strconv.ParseFloat(response.BalanceInfos[index].TotalBalance, 64) + if err != nil { + return 0, err + } + channel.UpdateBalance(balance) + return balance, nil +} + +func updateChannelAIGC2DBalance(channel *model.Channel) (float64, error) { + url := "https://api.aigc2d.com/dashboard/billing/credit_grants" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := APGC2DGPTUsageResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + channel.UpdateBalance(response.TotalAvailable) + return response.TotalAvailable, nil +} + +func updateChannelOpenRouterBalance(channel *model.Channel) (float64, error) { + url := "https://openrouter.ai/api/v1/credits" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := OpenRouterCreditResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + balance := response.Data.TotalCredits - response.Data.TotalUsage + channel.UpdateBalance(balance) + return balance, nil +} + +func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) { + url := "https://api.moonshot.cn/v1/users/me/balance" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + + type MoonshotBalanceData struct { + AvailableBalance float64 `json:"available_balance"` + VoucherBalance float64 `json:"voucher_balance"` + CashBalance float64 `json:"cash_balance"` + } + + type MoonshotBalanceResponse struct { + Code int `json:"code"` + Data MoonshotBalanceData `json:"data"` + Scode string `json:"scode"` + Status bool `json:"status"` + } + + response := MoonshotBalanceResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + if !response.Status || response.Code != 0 { + return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode) + } + availableBalanceCny := response.Data.AvailableBalance + availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(operation_setting.Price)).InexactFloat64() + channel.UpdateBalance(availableBalanceUsd) + return availableBalanceUsd, nil +} + +func updateChannelBalance(channel *model.Channel) (float64, error) { + baseURL := constant.ChannelBaseURLs[channel.Type] + if channel.GetBaseURL() == "" { + channel.BaseURL = &baseURL + } + switch channel.Type { + case constant.ChannelTypeOpenAI: + if channel.GetBaseURL() != "" { + baseURL = channel.GetBaseURL() + } + case constant.ChannelTypeAzure: + return 0, errors.New("尚未实现") + case constant.ChannelTypeCustom: + baseURL = channel.GetBaseURL() + //case common.ChannelTypeOpenAISB: + // return updateChannelOpenAISBBalance(channel) + case constant.ChannelTypeAIProxy: + return updateChannelAIProxyBalance(channel) + case constant.ChannelTypeAPI2GPT: + return updateChannelAPI2GPTBalance(channel) + case constant.ChannelTypeAIGC2D: + return updateChannelAIGC2DBalance(channel) + case constant.ChannelTypeSiliconFlow: + return updateChannelSiliconFlowBalance(channel) + case constant.ChannelTypeDeepSeek: + return updateChannelDeepSeekBalance(channel) + case constant.ChannelTypeOpenRouter: + return updateChannelOpenRouterBalance(channel) + case constant.ChannelTypeMoonshot: + return updateChannelMoonshotBalance(channel) + default: + return 0, errors.New("尚未实现") + } + url := fmt.Sprintf("%s/v1/dashboard/billing/subscription", baseURL) + + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + subscription := OpenAISubscriptionResponse{} + err = json.Unmarshal(body, &subscription) + if err != nil { + return 0, err + } + now := time.Now() + startDate := fmt.Sprintf("%s-01", now.Format("2006-01")) + endDate := now.Format("2006-01-02") + if !subscription.HasPaymentMethod { + startDate = now.AddDate(0, 0, -100).Format("2006-01-02") + } + url = fmt.Sprintf("%s/v1/dashboard/billing/usage?start_date=%s&end_date=%s", baseURL, startDate, endDate) + body, err = GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + usage := OpenAIUsageResponse{} + err = json.Unmarshal(body, &usage) + if err != nil { + return 0, err + } + balance := subscription.HardLimitUSD - usage.TotalUsage/100 + channel.UpdateBalance(balance) + return balance, nil +} + +func UpdateChannelBalance(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + channel, err := model.CacheGetChannel(id) + if err != nil { + common.ApiError(c, err) + return + } + if channel.ChannelInfo.IsMultiKey { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "多密钥渠道不支持余额查询", + }) + return + } + balance, err := updateChannelBalance(channel) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "balance": balance, + }) +} + +func updateAllChannelsBalance() error { + channels, err := model.GetAllChannels(0, 0, true, false) + if err != nil { + return err + } + for _, channel := range channels { + if channel.Status != common.ChannelStatusEnabled { + continue + } + if channel.ChannelInfo.IsMultiKey { + continue // skip multi-key channels + } + // TODO: support Azure + //if channel.Type != common.ChannelTypeOpenAI && channel.Type != common.ChannelTypeCustom { + // continue + //} + balance, err := updateChannelBalance(channel) + if err != nil { + continue + } else { + // err is nil & balance <= 0 means quota is used up + if balance <= 0 { + service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "余额不足") + } + } + time.Sleep(common.RequestInterval) + } + return nil +} + +func UpdateAllChannelsBalance(c *gin.Context) { + // TODO: make it async + err := updateAllChannelsBalance() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func AutomaticallyUpdateChannels(frequency int) { + for { + time.Sleep(time.Duration(frequency) * time.Minute) + common.SysLog("updating all channels") + _ = updateAllChannelsBalance() + common.SysLog("channels update done") + } +} diff --git a/controller/channel-test.go b/controller/channel-test.go new file mode 100644 index 0000000000000000000000000000000000000000..171cca22b23d4a5fc2a5a3f2c94d424463efc6aa --- /dev/null +++ b/controller/channel-test.go @@ -0,0 +1,643 @@ +package controller + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/samber/lo" + + "github.com/gin-gonic/gin" +) + +type testResult struct { + context *gin.Context + localErr error + newAPIError *types.NewAPIError +} + +func testChannel(channel *model.Channel, testModel string, endpointType string) testResult { + tik := time.Now() + var unsupportedTestChannelTypes = []int{ + constant.ChannelTypeMidjourney, + constant.ChannelTypeMidjourneyPlus, + constant.ChannelTypeSunoAPI, + constant.ChannelTypeKling, + constant.ChannelTypeJimeng, + constant.ChannelTypeDoubaoVideo, + constant.ChannelTypeVidu, + } + if lo.Contains(unsupportedTestChannelTypes, channel.Type) { + channelTypeName := constant.GetChannelTypeName(channel.Type) + return testResult{ + localErr: fmt.Errorf("%s channel test is not supported", channelTypeName), + } + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + testModel = strings.TrimSpace(testModel) + if testModel == "" { + if channel.TestModel != nil && *channel.TestModel != "" { + testModel = strings.TrimSpace(*channel.TestModel) + } else { + models := channel.GetModels() + if len(models) > 0 { + testModel = strings.TrimSpace(models[0]) + } + if testModel == "" { + testModel = "gpt-4o-mini" + } + } + } + + requestPath := "/v1/chat/completions" + + // 如果指定了端点类型,使用指定的端点类型 + if endpointType != "" { + if endpointInfo, ok := common.GetDefaultEndpointInfo(constant.EndpointType(endpointType)); ok { + requestPath = endpointInfo.Path + } + } else { + // 如果没有指定端点类型,使用原有的自动检测逻辑 + // 先判断是否为 Embedding 模型 + if strings.Contains(strings.ToLower(testModel), "embedding") || + strings.HasPrefix(testModel, "m3e") || // m3e 系列模型 + strings.Contains(testModel, "bge-") || // bge 系列模型 + strings.Contains(testModel, "embed") || + channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型 + requestPath = "/v1/embeddings" // 修改请求路径 + } + + // VolcEngine 图像生成模型 + if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") { + requestPath = "/v1/images/generations" + } + } + + c.Request = &http.Request{ + Method: "POST", + URL: &url.URL{Path: requestPath}, // 使用动态路径 + Body: nil, + Header: make(http.Header), + } + + cache, err := model.GetUserCache(1) + if err != nil { + return testResult{ + localErr: err, + newAPIError: nil, + } + } + cache.WriteContext(c) + + //c.Request.Header.Set("Authorization", "Bearer "+channel.Key) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("channel", channel.Type) + c.Set("base_url", channel.GetBaseURL()) + group, _ := model.GetUserGroup(1, false) + c.Set("group", group) + + newAPIError := middleware.SetupContextForSelectedChannel(c, channel, testModel) + if newAPIError != nil { + return testResult{ + context: c, + localErr: newAPIError, + newAPIError: newAPIError, + } + } + + // Determine relay format based on endpoint type or request path + var relayFormat types.RelayFormat + if endpointType != "" { + // 根据指定的端点类型设置 relayFormat + switch constant.EndpointType(endpointType) { + case constant.EndpointTypeOpenAI: + relayFormat = types.RelayFormatOpenAI + case constant.EndpointTypeOpenAIResponse: + relayFormat = types.RelayFormatOpenAIResponses + case constant.EndpointTypeAnthropic: + relayFormat = types.RelayFormatClaude + case constant.EndpointTypeGemini: + relayFormat = types.RelayFormatGemini + case constant.EndpointTypeJinaRerank: + relayFormat = types.RelayFormatRerank + case constant.EndpointTypeImageGeneration: + relayFormat = types.RelayFormatOpenAIImage + case constant.EndpointTypeEmbeddings: + relayFormat = types.RelayFormatEmbedding + default: + relayFormat = types.RelayFormatOpenAI + } + } else { + // 根据请求路径自动检测 + relayFormat = types.RelayFormatOpenAI + if c.Request.URL.Path == "/v1/embeddings" { + relayFormat = types.RelayFormatEmbedding + } + if c.Request.URL.Path == "/v1/images/generations" { + relayFormat = types.RelayFormatOpenAIImage + } + if c.Request.URL.Path == "/v1/messages" { + relayFormat = types.RelayFormatClaude + } + if strings.Contains(c.Request.URL.Path, "/v1beta/models") { + relayFormat = types.RelayFormatGemini + } + if c.Request.URL.Path == "/v1/rerank" || c.Request.URL.Path == "/rerank" { + relayFormat = types.RelayFormatRerank + } + if c.Request.URL.Path == "/v1/responses" { + relayFormat = types.RelayFormatOpenAIResponses + } + } + + request := buildTestRequest(testModel, endpointType) + + info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil) + + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeGenRelayInfoFailed), + } + } + + info.InitChannelMeta(c) + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeChannelModelMappedError), + } + } + + testModel = info.UpstreamModelName + // 更新请求中的模型名称 + request.SetModelName(testModel) + + apiType, _ := common.ChannelType2APIType(channel.Type) + adaptor := relay.GetAdaptor(apiType) + if adaptor == nil { + return testResult{ + context: c, + localErr: fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), + newAPIError: types.NewError(fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), types.ErrorCodeInvalidApiType), + } + } + + //// 创建一个用于日志的 info 副本,移除 ApiKey + //logInfo := info + //logInfo.ApiKey = "" + common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, info.ToString())) + + priceData, err := helper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta()) + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeModelPriceError), + } + } + + adaptor.Init(info) + + var convertedRequest any + // 根据 RelayMode 选择正确的转换函数 + switch info.RelayMode { + case relayconstant.RelayModeEmbeddings: + // Embedding 请求 - request 已经是正确的类型 + if embeddingReq, ok := request.(*dto.EmbeddingRequest); ok { + convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, *embeddingReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid embedding request type"), + newAPIError: types.NewError(errors.New("invalid embedding request type"), types.ErrorCodeConvertRequestFailed), + } + } + case relayconstant.RelayModeImagesGenerations: + // 图像生成请求 - request 已经是正确的类型 + if imageReq, ok := request.(*dto.ImageRequest); ok { + convertedRequest, err = adaptor.ConvertImageRequest(c, info, *imageReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid image request type"), + newAPIError: types.NewError(errors.New("invalid image request type"), types.ErrorCodeConvertRequestFailed), + } + } + case relayconstant.RelayModeRerank: + // Rerank 请求 - request 已经是正确的类型 + if rerankReq, ok := request.(*dto.RerankRequest); ok { + convertedRequest, err = adaptor.ConvertRerankRequest(c, info.RelayMode, *rerankReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid rerank request type"), + newAPIError: types.NewError(errors.New("invalid rerank request type"), types.ErrorCodeConvertRequestFailed), + } + } + case relayconstant.RelayModeResponses: + // Response 请求 - request 已经是正确的类型 + if responseReq, ok := request.(*dto.OpenAIResponsesRequest); ok { + convertedRequest, err = adaptor.ConvertOpenAIResponsesRequest(c, info, *responseReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid response request type"), + newAPIError: types.NewError(errors.New("invalid response request type"), types.ErrorCodeConvertRequestFailed), + } + } + default: + // Chat/Completion 等其他请求类型 + if generalReq, ok := request.(*dto.GeneralOpenAIRequest); ok { + convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, generalReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid general request type"), + newAPIError: types.NewError(errors.New("invalid general request type"), types.ErrorCodeConvertRequestFailed), + } + } + } + + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeConvertRequestFailed), + } + } + jsonData, err := json.Marshal(convertedRequest) + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeJsonMarshalFailed), + } + } + requestBody := bytes.NewBuffer(jsonData) + c.Request.Body = io.NopCloser(requestBody) + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError), + } + } + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + if httpResp.StatusCode != http.StatusOK { + err := service.RelayErrorHandler(c.Request.Context(), httpResp, true) + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError), + } + } + } + usageA, respErr := adaptor.DoResponse(c, httpResp, info) + if respErr != nil { + return testResult{ + context: c, + localErr: respErr, + newAPIError: respErr, + } + } + if usageA == nil { + return testResult{ + context: c, + localErr: errors.New("usage is nil"), + newAPIError: types.NewOpenAIError(errors.New("usage is nil"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError), + } + } + usage := usageA.(*dto.Usage) + result := w.Result() + respBody, err := io.ReadAll(result.Body) + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), + } + } + info.PromptTokens = usage.PromptTokens + + quota := 0 + if !priceData.UsePrice { + quota = usage.PromptTokens + int(math.Round(float64(usage.CompletionTokens)*priceData.CompletionRatio)) + quota = int(math.Round(float64(quota) * priceData.ModelRatio)) + if priceData.ModelRatio != 0 && quota <= 0 { + quota = 1 + } + } else { + quota = int(priceData.ModelPrice * common.QuotaPerUnit) + } + tok := time.Now() + milliseconds := tok.Sub(tik).Milliseconds() + consumedTime := float64(milliseconds) / 1000.0 + other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio, + usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) + model.RecordConsumeLog(c, 1, model.RecordConsumeLogParams{ + ChannelId: channel.Id, + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + ModelName: info.OriginModelName, + TokenName: "模型测试", + Quota: quota, + Content: "模型测试", + UseTimeSeconds: int(consumedTime), + IsStream: info.IsStream, + Group: info.UsingGroup, + Other: other, + }) + common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody))) + return testResult{ + context: c, + localErr: nil, + newAPIError: nil, + } +} + +func buildTestRequest(model string, endpointType string) dto.Request { + // 根据端点类型构建不同的测试请求 + if endpointType != "" { + switch constant.EndpointType(endpointType) { + case constant.EndpointTypeEmbeddings: + // 返回 EmbeddingRequest + return &dto.EmbeddingRequest{ + Model: model, + Input: []any{"hello world"}, + } + case constant.EndpointTypeImageGeneration: + // 返回 ImageRequest + return &dto.ImageRequest{ + Model: model, + Prompt: "a cute cat", + N: 1, + Size: "1024x1024", + } + case constant.EndpointTypeJinaRerank: + // 返回 RerankRequest + return &dto.RerankRequest{ + Model: model, + Query: "What is Deep Learning?", + Documents: []any{"Deep Learning is a subset of machine learning.", "Machine learning is a field of artificial intelligence."}, + TopN: 2, + } + case constant.EndpointTypeOpenAIResponse: + // 返回 OpenAIResponsesRequest + return &dto.OpenAIResponsesRequest{ + Model: model, + Input: json.RawMessage("\"hi\""), + } + case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI: + // 返回 GeneralOpenAIRequest + maxTokens := uint(10) + if constant.EndpointType(endpointType) == constant.EndpointTypeGemini { + maxTokens = 3000 + } + return &dto.GeneralOpenAIRequest{ + Model: model, + Stream: false, + Messages: []dto.Message{ + { + Role: "user", + Content: "hi", + }, + }, + MaxTokens: maxTokens, + } + } + } + + // 自动检测逻辑(保持原有行为) + // 先判断是否为 Embedding 模型 + if strings.Contains(strings.ToLower(model), "embedding") || + strings.HasPrefix(model, "m3e") || + strings.Contains(model, "bge-") { + // 返回 EmbeddingRequest + return &dto.EmbeddingRequest{ + Model: model, + Input: []any{"hello world"}, + } + } + + // Chat/Completion 请求 - 返回 GeneralOpenAIRequest + testRequest := &dto.GeneralOpenAIRequest{ + Model: model, + Stream: false, + Messages: []dto.Message{ + { + Role: "user", + Content: "hi", + }, + }, + } + + if strings.HasPrefix(model, "o") { + testRequest.MaxCompletionTokens = 10 + } else if strings.Contains(model, "thinking") { + if !strings.Contains(model, "claude") { + testRequest.MaxTokens = 50 + } + } else if strings.Contains(model, "gemini") { + testRequest.MaxTokens = 3000 + } else { + testRequest.MaxTokens = 10 + } + + return testRequest +} + +func TestChannel(c *gin.Context) { + channelId, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + channel, err := model.CacheGetChannel(channelId) + if err != nil { + channel, err = model.GetChannelById(channelId, true) + if err != nil { + common.ApiError(c, err) + return + } + } + //defer func() { + // if channel.ChannelInfo.IsMultiKey { + // go func() { _ = channel.SaveChannelInfo() }() + // } + //}() + testModel := c.Query("model") + endpointType := c.Query("endpoint_type") + tik := time.Now() + result := testChannel(channel, testModel, endpointType) + if result.localErr != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": result.localErr.Error(), + "time": 0.0, + }) + return + } + tok := time.Now() + milliseconds := tok.Sub(tik).Milliseconds() + go channel.UpdateResponseTime(milliseconds) + consumedTime := float64(milliseconds) / 1000.0 + if result.newAPIError != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": result.newAPIError.Error(), + "time": consumedTime, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "time": consumedTime, + }) +} + +var testAllChannelsLock sync.Mutex +var testAllChannelsRunning bool = false + +func testAllChannels(notify bool) error { + + testAllChannelsLock.Lock() + if testAllChannelsRunning { + testAllChannelsLock.Unlock() + return errors.New("测试已在运行中") + } + testAllChannelsRunning = true + testAllChannelsLock.Unlock() + channels, getChannelErr := model.GetAllChannels(0, 0, true, false) + if getChannelErr != nil { + return getChannelErr + } + var disableThreshold = int64(common.ChannelDisableThreshold * 1000) + if disableThreshold == 0 { + disableThreshold = 10000000 // a impossible value + } + gopool.Go(func() { + // 使用 defer 确保无论如何都会重置运行状态,防止死锁 + defer func() { + testAllChannelsLock.Lock() + testAllChannelsRunning = false + testAllChannelsLock.Unlock() + }() + + for _, channel := range channels { + isChannelEnabled := channel.Status == common.ChannelStatusEnabled + tik := time.Now() + result := testChannel(channel, "", "") + tok := time.Now() + milliseconds := tok.Sub(tik).Milliseconds() + + shouldBanChannel := false + newAPIError := result.newAPIError + // request error disables the channel + if newAPIError != nil { + shouldBanChannel = service.ShouldDisableChannel(channel.Type, result.newAPIError) + } + + // 当错误检查通过,才检查响应时间 + if common.AutomaticDisableChannelEnabled && !shouldBanChannel { + if milliseconds > disableThreshold { + err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0) + newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout) + shouldBanChannel = true + } + } + + // disable channel + if isChannelEnabled && shouldBanChannel && channel.GetAutoBan() { + processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError) + } + + // enable channel + if !isChannelEnabled && service.ShouldEnableChannel(newAPIError, channel.Status) { + service.EnableChannel(channel.Id, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.Name) + } + + channel.UpdateResponseTime(milliseconds) + time.Sleep(common.RequestInterval) + } + + if notify { + service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成") + } + }) + return nil +} + +func TestAllChannels(c *gin.Context) { + err := testAllChannels(true) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + +var autoTestChannelsOnce sync.Once + +func AutomaticallyTestChannels() { + // 只在Master节点定时测试渠道 + if !common.IsMasterNode { + return + } + autoTestChannelsOnce.Do(func() { + for { + if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled { + time.Sleep(1 * time.Minute) + continue + } + for { + frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes + time.Sleep(time.Duration(int(math.Round(frequency))) * time.Minute) + common.SysLog(fmt.Sprintf("automatically test channels with interval %f minutes", frequency)) + common.SysLog("automatically testing all channels") + _ = testAllChannels(false) + common.SysLog("automatically channel test finished") + if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled { + break + } + } + } + }) +} diff --git a/controller/channel.go b/controller/channel.go new file mode 100644 index 0000000000000000000000000000000000000000..31c802d8ccdf2c55bddb046a78a3551047a2e826 --- /dev/null +++ b/controller/channel.go @@ -0,0 +1,1610 @@ +package controller + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel/volcengine" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" +) + +type OpenAIModel struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + Permission []struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + AllowCreateEngine bool `json:"allow_create_engine"` + AllowSampling bool `json:"allow_sampling"` + AllowLogprobs bool `json:"allow_logprobs"` + AllowSearchIndices bool `json:"allow_search_indices"` + AllowView bool `json:"allow_view"` + AllowFineTuning bool `json:"allow_fine_tuning"` + Organization string `json:"organization"` + Group string `json:"group"` + IsBlocking bool `json:"is_blocking"` + } `json:"permission"` + Root string `json:"root"` + Parent string `json:"parent"` +} + +type OpenAIModelsResponse struct { + Data []OpenAIModel `json:"data"` + Success bool `json:"success"` +} + +func parseStatusFilter(statusParam string) int { + switch strings.ToLower(statusParam) { + case "enabled", "1": + return common.ChannelStatusEnabled + case "disabled", "0": + return 0 + default: + return -1 + } +} + +func clearChannelInfo(channel *model.Channel) { + if channel.ChannelInfo.IsMultiKey { + channel.ChannelInfo.MultiKeyDisabledReason = nil + channel.ChannelInfo.MultiKeyDisabledTime = nil + } +} + +func GetAllChannels(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + channelData := make([]*model.Channel, 0) + idSort, _ := strconv.ParseBool(c.Query("id_sort")) + enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode")) + statusParam := c.Query("status") + // statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual) + statusFilter := parseStatusFilter(statusParam) + // type filter + typeStr := c.Query("type") + typeFilter := -1 + if typeStr != "" { + if t, err := strconv.Atoi(typeStr); err == nil { + typeFilter = t + } + } + + var total int64 + + if enableTagMode { + tags, err := model.GetPaginatedTags(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + for _, tag := range tags { + if tag == nil || *tag == "" { + continue + } + tagChannels, err := model.GetChannelsByTag(*tag, idSort, false) + if err != nil { + continue + } + filtered := make([]*model.Channel, 0) + for _, ch := range tagChannels { + if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled { + continue + } + if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled { + continue + } + if typeFilter >= 0 && ch.Type != typeFilter { + continue + } + filtered = append(filtered, ch) + } + channelData = append(channelData, filtered...) + } + total, _ = model.CountAllTags() + } else { + baseQuery := model.DB.Model(&model.Channel{}) + if typeFilter >= 0 { + baseQuery = baseQuery.Where("type = ?", typeFilter) + } + if statusFilter == common.ChannelStatusEnabled { + baseQuery = baseQuery.Where("status = ?", common.ChannelStatusEnabled) + } else if statusFilter == 0 { + baseQuery = baseQuery.Where("status != ?", common.ChannelStatusEnabled) + } + + baseQuery.Count(&total) + + order := "priority desc" + if idSort { + order = "id desc" + } + + err := baseQuery.Order(order).Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("key").Find(&channelData).Error + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + } + + for _, datum := range channelData { + clearChannelInfo(datum) + } + + countQuery := model.DB.Model(&model.Channel{}) + if statusFilter == common.ChannelStatusEnabled { + countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled) + } else if statusFilter == 0 { + countQuery = countQuery.Where("status != ?", common.ChannelStatusEnabled) + } + var results []struct { + Type int64 + Count int64 + } + _ = countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error + typeCounts := make(map[int64]int64) + for _, r := range results { + typeCounts[r.Type] = r.Count + } + common.ApiSuccess(c, gin.H{ + "items": channelData, + "total": total, + "page": pageInfo.GetPage(), + "page_size": pageInfo.GetPageSize(), + "type_counts": typeCounts, + }) + return +} + +func FetchUpstreamModels(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + + channel, err := model.GetChannelById(id, true) + if err != nil { + common.ApiError(c, err) + return + } + + baseURL := constant.ChannelBaseURLs[channel.Type] + if channel.GetBaseURL() != "" { + baseURL = channel.GetBaseURL() + } + + var url string + switch channel.Type { + case constant.ChannelTypeGemini: + // curl https://example.com/v1beta/models?key=$GEMINI_API_KEY + url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remove key in url since we need to use AuthHeader + case constant.ChannelTypeAli: + url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL) + case constant.ChannelTypeZhipu_v4: + url = fmt.Sprintf("%s/api/paas/v4/models", baseURL) + case constant.ChannelTypeVolcEngine: + if baseURL == volcengine.DoubaoCodingPlan { + url = fmt.Sprintf("%s/v1/models", volcengine.DoubaoCodingPlanOpenAIBaseURL) + } else { + url = fmt.Sprintf("%s/v1/models", baseURL) + } + default: + url = fmt.Sprintf("%s/v1/models", baseURL) + } + + // 获取用于请求的可用密钥(多密钥渠道优先使用启用状态的密钥) + key, _, apiErr := channel.GetNextEnabledKey() + if apiErr != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("获取渠道密钥失败: %s", apiErr.Error()), + }) + return + } + key = strings.TrimSpace(key) + + // 获取响应体 - 根据渠道类型决定是否添加 AuthHeader + var body []byte + switch channel.Type { + case constant.ChannelTypeAnthropic: + body, err = GetResponseBody("GET", url, channel, GetClaudeAuthHeader(key)) + default: + body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key)) + } + if err != nil { + common.ApiError(c, err) + return + } + + var result OpenAIModelsResponse + if err = json.Unmarshal(body, &result); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("解析响应失败: %s", err.Error()), + }) + return + } + + var ids []string + for _, model := range result.Data { + id := model.ID + if channel.Type == constant.ChannelTypeGemini { + id = strings.TrimPrefix(id, "models/") + } + ids = append(ids, id) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": ids, + }) +} + +func FixChannelsAbilities(c *gin.Context) { + success, fails, err := model.FixAbility() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "success": success, + "fails": fails, + }, + }) +} + +func SearchChannels(c *gin.Context) { + keyword := c.Query("keyword") + group := c.Query("group") + modelKeyword := c.Query("model") + statusParam := c.Query("status") + statusFilter := parseStatusFilter(statusParam) + idSort, _ := strconv.ParseBool(c.Query("id_sort")) + enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode")) + channelData := make([]*model.Channel, 0) + if enableTagMode { + tags, err := model.SearchTags(keyword, group, modelKeyword, idSort) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + for _, tag := range tags { + if tag != nil && *tag != "" { + tagChannel, err := model.GetChannelsByTag(*tag, idSort, false) + if err == nil { + channelData = append(channelData, tagChannel...) + } + } + } + } else { + channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + channelData = channels + } + + if statusFilter == common.ChannelStatusEnabled || statusFilter == 0 { + filtered := make([]*model.Channel, 0, len(channelData)) + for _, ch := range channelData { + if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled { + continue + } + if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled { + continue + } + filtered = append(filtered, ch) + } + channelData = filtered + } + + // calculate type counts for search results + typeCounts := make(map[int64]int64) + for _, channel := range channelData { + typeCounts[int64(channel.Type)]++ + } + + typeParam := c.Query("type") + typeFilter := -1 + if typeParam != "" { + if tp, err := strconv.Atoi(typeParam); err == nil { + typeFilter = tp + } + } + + if typeFilter >= 0 { + filtered := make([]*model.Channel, 0, len(channelData)) + for _, ch := range channelData { + if ch.Type == typeFilter { + filtered = append(filtered, ch) + } + } + channelData = filtered + } + + page, _ := strconv.Atoi(c.DefaultQuery("p", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + + total := len(channelData) + startIdx := (page - 1) * pageSize + if startIdx > total { + startIdx = total + } + endIdx := startIdx + pageSize + if endIdx > total { + endIdx = total + } + + pagedData := channelData[startIdx:endIdx] + + for _, datum := range pagedData { + clearChannelInfo(datum) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "items": pagedData, + "total": total, + "type_counts": typeCounts, + }, + }) + return +} + +func GetChannel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + channel, err := model.GetChannelById(id, false) + if err != nil { + common.ApiError(c, err) + return + } + if channel != nil { + clearChannelInfo(channel) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": channel, + }) + return +} + +// GetChannelKey 获取渠道密钥(需要通过安全验证中间件) +// 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证 +func GetChannelKey(c *gin.Context) { + userId := c.GetInt("id") + channelId, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err)) + return + } + + // 获取渠道信息(包含密钥) + channel, err := model.GetChannelById(channelId, true) + if err != nil { + common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err)) + return + } + + if channel == nil { + common.ApiError(c, fmt.Errorf("渠道不存在")) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId)) + + // 返回渠道密钥 + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "获取成功", + "data": map[string]interface{}{ + "key": channel.Key, + }, + }) +} + +// validateTwoFactorAuth 统一的2FA验证函数 +func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool { + // 尝试验证TOTP + if cleanCode, err := common.ValidateNumericCode(code); err == nil { + if isValid, _ := twoFA.ValidateTOTPAndUpdateUsage(cleanCode); isValid { + return true + } + } + + // 尝试验证备用码 + if isValid, err := twoFA.ValidateBackupCodeAndUpdateUsage(code); err == nil && isValid { + return true + } + + return false +} + +// validateChannel 通用的渠道校验函数 +func validateChannel(channel *model.Channel, isAdd bool) error { + // 校验 channel settings + if err := channel.ValidateSettings(); err != nil { + return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error()) + } + + // 如果是添加操作,检查 channel 和 key 是否为空 + if isAdd { + if channel == nil || channel.Key == "" { + return fmt.Errorf("channel cannot be empty") + } + + // 检查模型名称长度是否超过 255 + for _, m := range channel.GetModels() { + if len(m) > 255 { + return fmt.Errorf("模型名称过长: %s", m) + } + } + } + + // VertexAI 特殊校验 + if channel.Type == constant.ChannelTypeVertexAi { + if channel.Other == "" { + return fmt.Errorf("部署地区不能为空") + } + + regionMap, err := common.StrToMap(channel.Other) + if err != nil { + return fmt.Errorf("部署地区必须是标准的Json格式,例如{\"default\": \"us-central1\", \"region2\": \"us-east1\"}") + } + + if regionMap["default"] == nil { + return fmt.Errorf("部署地区必须包含default字段") + } + } + + return nil +} + +type AddChannelRequest struct { + Mode string `json:"mode"` + MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` + BatchAddSetKeyPrefix2Name bool `json:"batch_add_set_key_prefix_2_name"` + Channel *model.Channel `json:"channel"` +} + +func getVertexArrayKeys(keys string) ([]string, error) { + if keys == "" { + return nil, nil + } + var keyArray []interface{} + err := common.Unmarshal([]byte(keys), &keyArray) + if err != nil { + return nil, fmt.Errorf("批量添加 Vertex AI 必须使用标准的JsonArray格式,例如[{key1}, {key2}...],请检查输入: %w", err) + } + cleanKeys := make([]string, 0, len(keyArray)) + for _, key := range keyArray { + var keyStr string + switch v := key.(type) { + case string: + keyStr = strings.TrimSpace(v) + default: + bytes, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("Vertex AI key JSON 编码失败: %w", err) + } + keyStr = string(bytes) + } + if keyStr != "" { + cleanKeys = append(cleanKeys, keyStr) + } + } + if len(cleanKeys) == 0 { + return nil, fmt.Errorf("批量添加 Vertex AI 的 keys 不能为空") + } + return cleanKeys, nil +} + +func AddChannel(c *gin.Context) { + addChannelRequest := AddChannelRequest{} + err := c.ShouldBindJSON(&addChannelRequest) + if err != nil { + common.ApiError(c, err) + return + } + + // 使用统一的校验函数 + if err := validateChannel(addChannelRequest.Channel, true); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + addChannelRequest.Channel.CreatedTime = common.GetTimestamp() + keys := make([]string, 0) + switch addChannelRequest.Mode { + case "multi_to_single": + addChannelRequest.Channel.ChannelInfo.IsMultiKey = true + addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode + if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { + array, err := getVertexArrayKeys(addChannelRequest.Channel.Key) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(array) + addChannelRequest.Channel.Key = strings.Join(array, "\n") + } else { + cleanKeys := make([]string, 0) + for _, key := range strings.Split(addChannelRequest.Channel.Key, "\n") { + if key == "" { + continue + } + key = strings.TrimSpace(key) + cleanKeys = append(cleanKeys, key) + } + addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(cleanKeys) + addChannelRequest.Channel.Key = strings.Join(cleanKeys, "\n") + } + keys = []string{addChannelRequest.Channel.Key} + case "batch": + if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { + // multi json + keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + keys = strings.Split(addChannelRequest.Channel.Key, "\n") + } + case "single": + keys = []string{addChannelRequest.Channel.Key} + default: + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "不支持的添加模式", + }) + return + } + + channels := make([]model.Channel, 0, len(keys)) + for _, key := range keys { + if key == "" { + continue + } + localChannel := addChannelRequest.Channel + localChannel.Key = key + if addChannelRequest.BatchAddSetKeyPrefix2Name && len(keys) > 1 { + keyPrefix := localChannel.Key + if len(localChannel.Key) > 8 { + keyPrefix = localChannel.Key[:8] + } + localChannel.Name = fmt.Sprintf("%s %s", localChannel.Name, keyPrefix) + } + channels = append(channels, *localChannel) + } + err = model.BatchInsertChannels(channels) + if err != nil { + common.ApiError(c, err) + return + } + service.ResetProxyClientCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func DeleteChannel(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + channel := model.Channel{Id: id} + err := channel.Delete() + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func DeleteDisabledChannel(c *gin.Context) { + rows, err := model.DeleteDisabledChannel() + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": rows, + }) + return +} + +type ChannelTag struct { + Tag string `json:"tag"` + NewTag *string `json:"new_tag"` + Priority *int64 `json:"priority"` + Weight *uint `json:"weight"` + ModelMapping *string `json:"model_mapping"` + Models *string `json:"models"` + Groups *string `json:"groups"` + ParamOverride *string `json:"param_override"` + HeaderOverride *string `json:"header_override"` +} + +func DisableTagChannels(c *gin.Context) { + channelTag := ChannelTag{} + err := c.ShouldBindJSON(&channelTag) + if err != nil || channelTag.Tag == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + err = model.DisableChannelByTag(channelTag.Tag) + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func EnableTagChannels(c *gin.Context) { + channelTag := ChannelTag{} + err := c.ShouldBindJSON(&channelTag) + if err != nil || channelTag.Tag == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + err = model.EnableChannelByTag(channelTag.Tag) + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func EditTagChannels(c *gin.Context) { + channelTag := ChannelTag{} + err := c.ShouldBindJSON(&channelTag) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + if channelTag.Tag == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "tag不能为空", + }) + return + } + if channelTag.ParamOverride != nil { + trimmed := strings.TrimSpace(*channelTag.ParamOverride) + if trimmed != "" && !json.Valid([]byte(trimmed)) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数覆盖必须是合法的 JSON 格式", + }) + return + } + channelTag.ParamOverride = common.GetPointer[string](trimmed) + } + if channelTag.HeaderOverride != nil { + trimmed := strings.TrimSpace(*channelTag.HeaderOverride) + if trimmed != "" && !json.Valid([]byte(trimmed)) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "请求头覆盖必须是合法的 JSON 格式", + }) + return + } + channelTag.HeaderOverride = common.GetPointer[string](trimmed) + } + err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight, channelTag.ParamOverride, channelTag.HeaderOverride) + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +type ChannelBatch struct { + Ids []int `json:"ids"` + Tag *string `json:"tag"` +} + +func DeleteChannelBatch(c *gin.Context) { + channelBatch := ChannelBatch{} + err := c.ShouldBindJSON(&channelBatch) + if err != nil || len(channelBatch.Ids) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + err = model.BatchDeleteChannels(channelBatch.Ids) + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": len(channelBatch.Ids), + }) + return +} + +type PatchChannel struct { + model.Channel + MultiKeyMode *string `json:"multi_key_mode"` + KeyMode *string `json:"key_mode"` // 多key模式下密钥覆盖或者追加 +} + +func UpdateChannel(c *gin.Context) { + channel := PatchChannel{} + err := c.ShouldBindJSON(&channel) + if err != nil { + common.ApiError(c, err) + return + } + + // 使用统一的校验函数 + if err := validateChannel(&channel.Channel, false); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + // Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request. + originChannel, err := model.GetChannelById(channel.Id, true) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + // Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained. + channel.ChannelInfo = originChannel.ChannelInfo + + // If the request explicitly specifies a new MultiKeyMode, apply it on top of the original info. + if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" { + channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode) + } + + // 处理多key模式下的密钥追加/覆盖逻辑 + if channel.KeyMode != nil && channel.ChannelInfo.IsMultiKey { + switch *channel.KeyMode { + case "append": + // 追加模式:将新密钥添加到现有密钥列表 + if originChannel.Key != "" { + var newKeys []string + var existingKeys []string + + // 解析现有密钥 + if strings.HasPrefix(strings.TrimSpace(originChannel.Key), "[") { + // JSON数组格式 + var arr []json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(originChannel.Key)), &arr); err == nil { + existingKeys = make([]string, len(arr)) + for i, v := range arr { + existingKeys[i] = string(v) + } + } + } else { + // 换行分隔格式 + existingKeys = strings.Split(strings.Trim(originChannel.Key, "\n"), "\n") + } + + // 处理 Vertex AI 的特殊情况 + if channel.Type == constant.ChannelTypeVertexAi && channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { + // 尝试解析新密钥为JSON数组 + if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") { + array, err := getVertexArrayKeys(channel.Key) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "追加密钥解析失败: " + err.Error(), + }) + return + } + newKeys = array + } else { + // 单个JSON密钥 + newKeys = []string{channel.Key} + } + // 合并密钥 + allKeys := append(existingKeys, newKeys...) + channel.Key = strings.Join(allKeys, "\n") + } else { + // 普通渠道的处理 + inputKeys := strings.Split(channel.Key, "\n") + for _, key := range inputKeys { + key = strings.TrimSpace(key) + if key != "" { + newKeys = append(newKeys, key) + } + } + // 合并密钥 + allKeys := append(existingKeys, newKeys...) + channel.Key = strings.Join(allKeys, "\n") + } + } + case "replace": + // 覆盖模式:直接使用新密钥(默认行为,不需要特殊处理) + } + } + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + service.ResetProxyClientCache() + channel.Key = "" + clearChannelInfo(&channel.Channel) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": channel, + }) + return +} + +func FetchModels(c *gin.Context) { + var req struct { + BaseURL string `json:"base_url"` + Type int `json:"type"` + Key string `json:"key"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request", + }) + return + } + + baseURL := req.BaseURL + if baseURL == "" { + baseURL = constant.ChannelBaseURLs[req.Type] + } + + client := &http.Client{} + url := fmt.Sprintf("%s/v1/models", baseURL) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + // remove line breaks and extra spaces. + key := strings.TrimSpace(req.Key) + // If the key contains a line break, only take the first part. + key = strings.Split(key, "\n")[0] + request.Header.Set("Authorization", "Bearer "+key) + + response, err := client.Do(request) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + //check status code + if response.StatusCode != http.StatusOK { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to fetch models", + }) + return + } + defer response.Body.Close() + + var result struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + var models []string + for _, model := range result.Data { + models = append(models, model.ID) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": models, + }) +} + +func BatchSetChannelTag(c *gin.Context) { + channelBatch := ChannelBatch{} + err := c.ShouldBindJSON(&channelBatch) + if err != nil || len(channelBatch.Ids) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + err = model.BatchSetChannelTag(channelBatch.Ids, channelBatch.Tag) + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": len(channelBatch.Ids), + }) + return +} + +func GetTagModels(c *gin.Context) { + tag := c.Query("tag") + if tag == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "tag不能为空", + }) + return + } + + channels, err := model.GetChannelsByTag(tag, false, false) // idSort=false, selectAll=false + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + var longestModels string + maxLength := 0 + + // Find the longest models string among all channels with the given tag + for _, channel := range channels { + if channel.Models != "" { + currentModels := strings.Split(channel.Models, ",") + if len(currentModels) > maxLength { + maxLength = len(currentModels) + longestModels = channel.Models + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": longestModels, + }) + return +} + +// CopyChannel handles cloning an existing channel with its key. +// POST /api/channel/copy/:id +// Optional query params: +// +// suffix - string appended to the original name (default "_复制") +// reset_balance - bool, when true will reset balance & used_quota to 0 (default true) +func CopyChannel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid id"}) + return + } + + suffix := c.DefaultQuery("suffix", "_复制") + resetBalance := true + if rbStr := c.DefaultQuery("reset_balance", "true"); rbStr != "" { + if v, err := strconv.ParseBool(rbStr); err == nil { + resetBalance = v + } + } + + // fetch original channel with key + origin, err := model.GetChannelById(id, true) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + // clone channel + clone := *origin // shallow copy is sufficient as we will overwrite primitives + clone.Id = 0 // let DB auto-generate + clone.CreatedTime = common.GetTimestamp() + clone.Name = origin.Name + suffix + clone.TestTime = 0 + clone.ResponseTime = 0 + if resetBalance { + clone.Balance = 0 + clone.UsedQuota = 0 + } + + // insert + if err := model.BatchInsertChannels([]model.Channel{clone}); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + model.InitChannelCache() + // success + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}}) +} + +// MultiKeyManageRequest represents the request for multi-key management operations +type MultiKeyManageRequest struct { + ChannelId int `json:"channel_id"` + Action string `json:"action"` // "disable_key", "enable_key", "delete_key", "delete_disabled_keys", "get_key_status" + KeyIndex *int `json:"key_index,omitempty"` // for disable_key, enable_key, and delete_key actions + Page int `json:"page,omitempty"` // for get_key_status pagination + PageSize int `json:"page_size,omitempty"` // for get_key_status pagination + Status *int `json:"status,omitempty"` // for get_key_status filtering: 1=enabled, 2=manual_disabled, 3=auto_disabled, nil=all +} + +// MultiKeyStatusResponse represents the response for key status query +type MultiKeyStatusResponse struct { + Keys []KeyStatus `json:"keys"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` + // Statistics + EnabledCount int `json:"enabled_count"` + ManualDisabledCount int `json:"manual_disabled_count"` + AutoDisabledCount int `json:"auto_disabled_count"` +} + +type KeyStatus struct { + Index int `json:"index"` + Status int `json:"status"` // 1: enabled, 2: disabled + DisabledTime int64 `json:"disabled_time,omitempty"` + Reason string `json:"reason,omitempty"` + KeyPreview string `json:"key_preview"` // first 10 chars of key for identification +} + +// ManageMultiKeys handles multi-key management operations +func ManageMultiKeys(c *gin.Context) { + request := MultiKeyManageRequest{} + err := c.ShouldBindJSON(&request) + if err != nil { + common.ApiError(c, err) + return + } + + channel, err := model.GetChannelById(request.ChannelId, true) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "渠道不存在", + }) + return + } + + if !channel.ChannelInfo.IsMultiKey { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该渠道不是多密钥模式", + }) + return + } + + lock := model.GetChannelPollingLock(channel.Id) + lock.Lock() + defer lock.Unlock() + + switch request.Action { + case "get_key_status": + keys := channel.GetKeys() + + // Default pagination parameters + page := request.Page + pageSize := request.PageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 // Default page size + } + + // Statistics for all keys (unchanged by filtering) + var enabledCount, manualDisabledCount, autoDisabledCount int + + // Build all key status data first + var allKeyStatusList []KeyStatus + for i, key := range keys { + status := 1 // default enabled + var disabledTime int64 + var reason string + + if channel.ChannelInfo.MultiKeyStatusList != nil { + if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists { + status = s + } + } + + // Count for statistics (all keys) + switch status { + case 1: + enabledCount++ + case 2: + manualDisabledCount++ + case 3: + autoDisabledCount++ + } + + if status != 1 { + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + disabledTime = channel.ChannelInfo.MultiKeyDisabledTime[i] + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + reason = channel.ChannelInfo.MultiKeyDisabledReason[i] + } + } + + // Create key preview (first 10 chars) + keyPreview := key + if len(key) > 10 { + keyPreview = key[:10] + "..." + } + + allKeyStatusList = append(allKeyStatusList, KeyStatus{ + Index: i, + Status: status, + DisabledTime: disabledTime, + Reason: reason, + KeyPreview: keyPreview, + }) + } + + // Apply status filter if specified + var filteredKeyStatusList []KeyStatus + if request.Status != nil { + for _, keyStatus := range allKeyStatusList { + if keyStatus.Status == *request.Status { + filteredKeyStatusList = append(filteredKeyStatusList, keyStatus) + } + } + } else { + filteredKeyStatusList = allKeyStatusList + } + + // Calculate pagination based on filtered results + filteredTotal := len(filteredKeyStatusList) + totalPages := (filteredTotal + pageSize - 1) / pageSize + if totalPages == 0 { + totalPages = 1 + } + if page > totalPages { + page = totalPages + } + + // Calculate range for current page + start := (page - 1) * pageSize + end := start + pageSize + if end > filteredTotal { + end = filteredTotal + } + + // Get the page data + var pageKeyStatusList []KeyStatus + if start < filteredTotal { + pageKeyStatusList = filteredKeyStatusList[start:end] + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": MultiKeyStatusResponse{ + Keys: pageKeyStatusList, + Total: filteredTotal, // Total of filtered results + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + EnabledCount: enabledCount, // Overall statistics + ManualDisabledCount: manualDisabledCount, // Overall statistics + AutoDisabledCount: autoDisabledCount, // Overall statistics + }, + }) + return + + case "disable_key": + if request.KeyIndex == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "未指定要禁用的密钥索引", + }) + return + } + + keyIndex := *request.KeyIndex + if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "密钥索引超出范围", + }) + return + } + + if channel.ChannelInfo.MultiKeyStatusList == nil { + channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) + } + if channel.ChannelInfo.MultiKeyDisabledTime == nil { + channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) + } + if channel.ChannelInfo.MultiKeyDisabledReason == nil { + channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) + } + + channel.ChannelInfo.MultiKeyStatusList[keyIndex] = 2 // disabled + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "密钥已禁用", + }) + return + + case "enable_key": + if request.KeyIndex == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "未指定要启用的密钥索引", + }) + return + } + + keyIndex := *request.KeyIndex + if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "密钥索引超出范围", + }) + return + } + + // 从状态列表中删除该密钥的记录,使其回到默认启用状态 + if channel.ChannelInfo.MultiKeyStatusList != nil { + delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex) + } + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + delete(channel.ChannelInfo.MultiKeyDisabledTime, keyIndex) + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + delete(channel.ChannelInfo.MultiKeyDisabledReason, keyIndex) + } + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "密钥已启用", + }) + return + + case "enable_all_keys": + // 清空所有禁用状态,使所有密钥回到默认启用状态 + var enabledCount int + if channel.ChannelInfo.MultiKeyStatusList != nil { + enabledCount = len(channel.ChannelInfo.MultiKeyStatusList) + } + + channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) + channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) + channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": fmt.Sprintf("已启用 %d 个密钥", enabledCount), + }) + return + + case "disable_all_keys": + // 禁用所有启用的密钥 + if channel.ChannelInfo.MultiKeyStatusList == nil { + channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) + } + if channel.ChannelInfo.MultiKeyDisabledTime == nil { + channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) + } + if channel.ChannelInfo.MultiKeyDisabledReason == nil { + channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) + } + + var disabledCount int + for i := 0; i < channel.ChannelInfo.MultiKeySize; i++ { + status := 1 // default enabled + if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists { + status = s + } + + // 只禁用当前启用的密钥 + if status == 1 { + channel.ChannelInfo.MultiKeyStatusList[i] = 2 // disabled + disabledCount++ + } + } + + if disabledCount == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "没有可禁用的密钥", + }) + return + } + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": fmt.Sprintf("已禁用 %d 个密钥", disabledCount), + }) + return + + case "delete_key": + if request.KeyIndex == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "未指定要删除的密钥索引", + }) + return + } + + keyIndex := *request.KeyIndex + if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "密钥索引超出范围", + }) + return + } + + keys := channel.GetKeys() + var remainingKeys []string + var newStatusList = make(map[int]int) + var newDisabledTime = make(map[int]int64) + var newDisabledReason = make(map[int]string) + + newIndex := 0 + for i, key := range keys { + // 跳过要删除的密钥 + if i == keyIndex { + continue + } + + remainingKeys = append(remainingKeys, key) + + // 保留其他密钥的状态信息,重新索引 + if channel.ChannelInfo.MultiKeyStatusList != nil { + if status, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists && status != 1 { + newStatusList[newIndex] = status + } + } + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists { + newDisabledTime[newIndex] = t + } + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists { + newDisabledReason[newIndex] = r + } + } + newIndex++ + } + + if len(remainingKeys) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "不能删除最后一个密钥", + }) + return + } + + // Update channel with remaining keys + channel.Key = strings.Join(remainingKeys, "\n") + channel.ChannelInfo.MultiKeySize = len(remainingKeys) + channel.ChannelInfo.MultiKeyStatusList = newStatusList + channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime + channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "密钥已删除", + }) + return + + case "delete_disabled_keys": + keys := channel.GetKeys() + var remainingKeys []string + var deletedCount int + var newStatusList = make(map[int]int) + var newDisabledTime = make(map[int]int64) + var newDisabledReason = make(map[int]string) + + newIndex := 0 + for i, key := range keys { + status := 1 // default enabled + if channel.ChannelInfo.MultiKeyStatusList != nil { + if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists { + status = s + } + } + + // 只删除自动禁用(status == 3)的密钥,保留启用(status == 1)和手动禁用(status == 2)的密钥 + if status == 3 { + deletedCount++ + } else { + remainingKeys = append(remainingKeys, key) + // 保留非自动禁用密钥的状态信息,重新索引 + if status != 1 { + newStatusList[newIndex] = status + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists { + newDisabledTime[newIndex] = t + } + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists { + newDisabledReason[newIndex] = r + } + } + } + newIndex++ + } + } + + if deletedCount == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "没有需要删除的自动禁用密钥", + }) + return + } + + // Update channel with remaining keys + channel.Key = strings.Join(remainingKeys, "\n") + channel.ChannelInfo.MultiKeySize = len(remainingKeys) + channel.ChannelInfo.MultiKeyStatusList = newStatusList + channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime + channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": fmt.Sprintf("已删除 %d 个自动禁用的密钥", deletedCount), + "data": deletedCount, + }) + return + + default: + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "不支持的操作", + }) + return + } +} diff --git a/controller/console_migrate.go b/controller/console_migrate.go new file mode 100644 index 0000000000000000000000000000000000000000..011ab09d43d571da3cdf7bc443d2bd80380f3191 --- /dev/null +++ b/controller/console_migrate.go @@ -0,0 +1,105 @@ +// 用于迁移检测的旧键,该文件下个版本会删除 + +package controller + +import ( + "encoding/json" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +// MigrateConsoleSetting 迁移旧的控制台相关配置到 console_setting.* +func MigrateConsoleSetting(c *gin.Context) { + // 读取全部 option + opts, err := model.AllOption() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) + return + } + // 建立 map + valMap := map[string]string{} + for _, o := range opts { + valMap[o.Key] = o.Value + } + + // 处理 APIInfo + if v := valMap["ApiInfo"]; v != "" { + var arr []map[string]interface{} + if err := json.Unmarshal([]byte(v), &arr); err == nil { + if len(arr) > 50 { + arr = arr[:50] + } + bytes, _ := json.Marshal(arr) + model.UpdateOption("console_setting.api_info", string(bytes)) + } + model.UpdateOption("ApiInfo", "") + } + // Announcements 直接搬 + if v := valMap["Announcements"]; v != "" { + model.UpdateOption("console_setting.announcements", v) + model.UpdateOption("Announcements", "") + } + // FAQ 转换 + if v := valMap["FAQ"]; v != "" { + var arr []map[string]interface{} + if err := json.Unmarshal([]byte(v), &arr); err == nil { + out := []map[string]interface{}{} + for _, item := range arr { + q, _ := item["question"].(string) + if q == "" { + q, _ = item["title"].(string) + } + a, _ := item["answer"].(string) + if a == "" { + a, _ = item["content"].(string) + } + if q != "" && a != "" { + out = append(out, map[string]interface{}{"question": q, "answer": a}) + } + } + if len(out) > 50 { + out = out[:50] + } + bytes, _ := json.Marshal(out) + model.UpdateOption("console_setting.faq", string(bytes)) + } + model.UpdateOption("FAQ", "") + } + // Uptime Kuma 迁移到新的 groups 结构(console_setting.uptime_kuma_groups) + url := valMap["UptimeKumaUrl"] + slug := valMap["UptimeKumaSlug"] + if url != "" && slug != "" { + // 仅当同时存在 URL 与 Slug 时才进行迁移 + groups := []map[string]interface{}{ + { + "id": 1, + "categoryName": "old", + "url": url, + "slug": slug, + "description": "", + }, + } + bytes, _ := json.Marshal(groups) + model.UpdateOption("console_setting.uptime_kuma_groups", string(bytes)) + } + // 清空旧键内容 + if url != "" { + model.UpdateOption("UptimeKumaUrl", "") + } + if slug != "" { + model.UpdateOption("UptimeKumaSlug", "") + } + + // 删除旧键记录 + oldKeys := []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"} + model.DB.Where("key IN ?", oldKeys).Delete(&model.Option{}) + + // 重新加载 OptionMap + model.InitOptionMap() + common.SysLog("console setting migrated") + c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"}) +} diff --git a/controller/discord.go b/controller/discord.go new file mode 100644 index 0000000000000000000000000000000000000000..41dd59808a7da4305d30d1b374a4827b53ef7253 --- /dev/null +++ b/controller/discord.go @@ -0,0 +1,223 @@ +package controller + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type DiscordResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + +type DiscordUser struct { + UID string `json:"id"` + ID string `json:"username"` + Name string `json:"global_name"` +} + +func getDiscordUserInfoByCode(code string) (*DiscordUser, error) { + if code == "" { + return nil, errors.New("无效的参数") + } + + values := url.Values{} + values.Set("client_id", system_setting.GetDiscordSettings().ClientId) + values.Set("client_secret", system_setting.GetDiscordSettings().ClientSecret) + values.Set("code", code) + values.Set("grant_type", "authorization_code") + values.Set("redirect_uri", fmt.Sprintf("%s/oauth/discord", system_setting.ServerAddress)) + formData := values.Encode() + req, err := http.NewRequest("POST", "https://discord.com/api/v10/oauth2/token", strings.NewReader(formData)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + client := http.Client{ + Timeout: 5 * time.Second, + } + res, err := client.Do(req) + if err != nil { + common.SysLog(err.Error()) + return nil, errors.New("无法连接至 Discord 服务器,请稍后重试!") + } + defer res.Body.Close() + var discordResponse DiscordResponse + err = json.NewDecoder(res.Body).Decode(&discordResponse) + if err != nil { + return nil, err + } + + if discordResponse.AccessToken == "" { + common.SysError("Discord 获取 Token 失败,请检查设置!") + return nil, errors.New("Discord 获取 Token 失败,请检查设置!") + } + + req, err = http.NewRequest("GET", "https://discord.com/api/v10/users/@me", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+discordResponse.AccessToken) + res2, err := client.Do(req) + if err != nil { + common.SysLog(err.Error()) + return nil, errors.New("无法连接至 Discord 服务器,请稍后重试!") + } + defer res2.Body.Close() + if res2.StatusCode != http.StatusOK { + common.SysError("Discord 获取用户信息失败!请检查设置!") + return nil, errors.New("Discord 获取用户信息失败!请检查设置!") + } + + var discordUser DiscordUser + err = json.NewDecoder(res2.Body).Decode(&discordUser) + if err != nil { + return nil, err + } + if discordUser.UID == "" || discordUser.ID == "" { + common.SysError("Discord 获取用户信息为空!请检查设置!") + return nil, errors.New("Discord 获取用户信息为空!请检查设置!") + } + return &discordUser, nil +} + +func DiscordOAuth(c *gin.Context) { + session := sessions.Default(c) + state := c.Query("state") + if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "state is empty or not same", + }) + return + } + username := session.Get("username") + if username != nil { + DiscordBind(c) + return + } + if !system_setting.GetDiscordSettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 Discord 登录以及注册", + }) + return + } + code := c.Query("code") + discordUser, err := getDiscordUserInfoByCode(code) + if err != nil { + common.ApiError(c, err) + return + } + user := model.User{ + DiscordId: discordUser.UID, + } + if model.IsDiscordIdAlreadyTaken(user.DiscordId) { + err := user.FillUserByDiscordId() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + if common.RegisterEnabled { + if discordUser.ID != "" { + user.Username = discordUser.ID + } else { + user.Username = "discord_" + strconv.Itoa(model.GetMaxUserId()+1) + } + if discordUser.Name != "" { + user.DisplayName = discordUser.Name + } else { + user.DisplayName = "Discord User" + } + err := user.Insert(0) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员关闭了新用户注册", + }) + return + } + } + + if user.Status != common.UserStatusEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "用户已被封禁", + "success": false, + }) + return + } + setupLogin(&user, c) +} + +func DiscordBind(c *gin.Context) { + if !system_setting.GetDiscordSettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 Discord 登录以及注册", + }) + return + } + code := c.Query("code") + discordUser, err := getDiscordUserInfoByCode(code) + if err != nil { + common.ApiError(c, err) + return + } + user := model.User{ + DiscordId: discordUser.UID, + } + if model.IsDiscordIdAlreadyTaken(user.DiscordId) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该 Discord 账户已被绑定", + }) + return + } + session := sessions.Default(c) + id := session.Get("id") + user.Id = id.(int) + err = user.FillUserById() + if err != nil { + common.ApiError(c, err) + return + } + user.DiscordId = discordUser.UID + err = user.Update(false) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "bind", + }) +} diff --git a/controller/github.go b/controller/github.go new file mode 100644 index 0000000000000000000000000000000000000000..79f27bca339e7cf2e1911ba1e59c146f4fc0b198 --- /dev/null +++ b/controller/github.go @@ -0,0 +1,240 @@ +package controller + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type GitHubOAuthResponse struct { + AccessToken string `json:"access_token"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` +} + +type GitHubUser struct { + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` +} + +func getGitHubUserInfoByCode(code string) (*GitHubUser, error) { + if code == "" { + return nil, errors.New("无效的参数") + } + values := map[string]string{"client_id": common.GitHubClientId, "client_secret": common.GitHubClientSecret, "code": code} + jsonData, err := json.Marshal(values) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + client := http.Client{ + Timeout: 20 * time.Second, + } + res, err := client.Do(req) + if err != nil { + common.SysLog(err.Error()) + return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!") + } + defer res.Body.Close() + var oAuthResponse GitHubOAuthResponse + err = json.NewDecoder(res.Body).Decode(&oAuthResponse) + if err != nil { + return nil, err + } + req, err = http.NewRequest("GET", "https://api.github.com/user", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oAuthResponse.AccessToken)) + res2, err := client.Do(req) + if err != nil { + common.SysLog(err.Error()) + return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!") + } + defer res2.Body.Close() + var githubUser GitHubUser + err = json.NewDecoder(res2.Body).Decode(&githubUser) + if err != nil { + return nil, err + } + if githubUser.Login == "" { + return nil, errors.New("返回值非法,用户字段为空,请稍后重试!") + } + return &githubUser, nil +} + +func GitHubOAuth(c *gin.Context) { + session := sessions.Default(c) + state := c.Query("state") + if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "state is empty or not same", + }) + return + } + username := session.Get("username") + if username != nil { + GitHubBind(c) + return + } + + if !common.GitHubOAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 GitHub 登录以及注册", + }) + return + } + code := c.Query("code") + githubUser, err := getGitHubUserInfoByCode(code) + if err != nil { + common.ApiError(c, err) + return + } + user := model.User{ + GitHubId: githubUser.Login, + } + // IsGitHubIdAlreadyTaken is unscoped + if model.IsGitHubIdAlreadyTaken(user.GitHubId) { + // FillUserByGitHubId is scoped + err := user.FillUserByGitHubId() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + // if user.Id == 0 , user has been deleted + if user.Id == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已注销", + }) + return + } + } else { + if common.RegisterEnabled { + user.Username = "github_" + strconv.Itoa(model.GetMaxUserId()+1) + if githubUser.Name != "" { + user.DisplayName = githubUser.Name + } else { + user.DisplayName = "GitHub User" + } + user.Email = githubUser.Email + user.Role = common.RoleCommonUser + user.Status = common.UserStatusEnabled + affCode := session.Get("aff") + inviterId := 0 + if affCode != nil { + inviterId, _ = model.GetUserIdByAffCode(affCode.(string)) + } + + if err := user.Insert(inviterId); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员关闭了新用户注册", + }) + return + } + } + + if user.Status != common.UserStatusEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "用户已被封禁", + "success": false, + }) + return + } + setupLogin(&user, c) +} + +func GitHubBind(c *gin.Context) { + if !common.GitHubOAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 GitHub 登录以及注册", + }) + return + } + code := c.Query("code") + githubUser, err := getGitHubUserInfoByCode(code) + if err != nil { + common.ApiError(c, err) + return + } + user := model.User{ + GitHubId: githubUser.Login, + } + if model.IsGitHubIdAlreadyTaken(user.GitHubId) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该 GitHub 账户已被绑定", + }) + return + } + session := sessions.Default(c) + id := session.Get("id") + // id := c.GetInt("id") // critical bug! + user.Id = id.(int) + err = user.FillUserById() + if err != nil { + common.ApiError(c, err) + return + } + user.GitHubId = githubUser.Login + err = user.Update(false) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "bind", + }) + return +} + +func GenerateOAuthCode(c *gin.Context) { + session := sessions.Default(c) + state := common.GetRandomString(12) + affCode := c.Query("aff") + if affCode != "" { + session.Set("aff", affCode) + } + session.Set("oauth_state", state) + err := session.Save() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": state, + }) +} diff --git a/controller/group.go b/controller/group.go new file mode 100644 index 0000000000000000000000000000000000000000..6ba339a3f9bdfc163bd3ccb6e8495b62976464c8 --- /dev/null +++ b/controller/group.go @@ -0,0 +1,52 @@ +package controller + +import ( + "net/http" + + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +func GetGroups(c *gin.Context) { + groupNames := make([]string, 0) + for groupName := range ratio_setting.GetGroupRatioCopy() { + groupNames = append(groupNames, groupName) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": groupNames, + }) +} + +func GetUserGroups(c *gin.Context) { + usableGroups := make(map[string]map[string]interface{}) + userGroup := "" + userId := c.GetInt("id") + userGroup, _ = model.GetUserGroup(userId, false) + userUsableGroups := service.GetUserUsableGroups(userGroup) + for groupName, _ := range ratio_setting.GetGroupRatioCopy() { + // UserUsableGroups contains the groups that the user can use + if desc, ok := userUsableGroups[groupName]; ok { + usableGroups[groupName] = map[string]interface{}{ + "ratio": service.GetUserGroupRatio(userGroup, groupName), + "desc": desc, + } + } + } + if _, ok := userUsableGroups["auto"]; ok { + usableGroups["auto"] = map[string]interface{}{ + "ratio": "自动", + "desc": setting.GetUsableGroupDescription("auto"), + } + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": usableGroups, + }) +} diff --git a/controller/image.go b/controller/image.go new file mode 100644 index 0000000000000000000000000000000000000000..d6e8806ad8dd088d4b4428e8a19bebd659f75fd7 --- /dev/null +++ b/controller/image.go @@ -0,0 +1,9 @@ +package controller + +import ( + "github.com/gin-gonic/gin" +) + +func GetImage(c *gin.Context) { + +} diff --git a/controller/linuxdo.go b/controller/linuxdo.go new file mode 100644 index 0000000000000000000000000000000000000000..5457c9a4ffdf547f253f62c01e9497f3a33fda73 --- /dev/null +++ b/controller/linuxdo.go @@ -0,0 +1,268 @@ +package controller + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type LinuxdoUser struct { + Id int `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Active bool `json:"active"` + TrustLevel int `json:"trust_level"` + Silenced bool `json:"silenced"` +} + +func LinuxDoBind(c *gin.Context) { + if !common.LinuxDOOAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 Linux DO 登录以及注册", + }) + return + } + + code := c.Query("code") + linuxdoUser, err := getLinuxdoUserInfoByCode(code, c) + if err != nil { + common.ApiError(c, err) + return + } + + user := model.User{ + LinuxDOId: strconv.Itoa(linuxdoUser.Id), + } + + if model.IsLinuxDOIdAlreadyTaken(user.LinuxDOId) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该 Linux DO 账户已被绑定", + }) + return + } + + session := sessions.Default(c) + id := session.Get("id") + user.Id = id.(int) + + err = user.FillUserById() + if err != nil { + common.ApiError(c, err) + return + } + + user.LinuxDOId = strconv.Itoa(linuxdoUser.Id) + err = user.Update(false) + if err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "bind", + }) +} + +func getLinuxdoUserInfoByCode(code string, c *gin.Context) (*LinuxdoUser, error) { + if code == "" { + return nil, errors.New("invalid code") + } + + // Get access token using Basic auth + tokenEndpoint := common.GetEnvOrDefaultString("LINUX_DO_TOKEN_ENDPOINT", "https://connect.linux.do/oauth2/token") + credentials := common.LinuxDOClientId + ":" + common.LinuxDOClientSecret + basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials)) + + // Get redirect URI from request + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + redirectURI := fmt.Sprintf("%s://%s/api/oauth/linuxdo", scheme, c.Request.Host) + + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("redirect_uri", redirectURI) + + req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", basicAuth) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + client := http.Client{Timeout: 5 * time.Second} + res, err := client.Do(req) + if err != nil { + return nil, errors.New("failed to connect to Linux DO server") + } + defer res.Body.Close() + + var tokenRes struct { + AccessToken string `json:"access_token"` + Message string `json:"message"` + } + if err := json.NewDecoder(res.Body).Decode(&tokenRes); err != nil { + return nil, err + } + + if tokenRes.AccessToken == "" { + return nil, fmt.Errorf("failed to get access token: %s", tokenRes.Message) + } + + // Get user info + userEndpoint := common.GetEnvOrDefaultString("LINUX_DO_USER_ENDPOINT", "https://connect.linux.do/api/user") + req, err = http.NewRequest("GET", userEndpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tokenRes.AccessToken) + req.Header.Set("Accept", "application/json") + + res2, err := client.Do(req) + if err != nil { + return nil, errors.New("failed to get user info from Linux DO") + } + defer res2.Body.Close() + + var linuxdoUser LinuxdoUser + if err := json.NewDecoder(res2.Body).Decode(&linuxdoUser); err != nil { + return nil, err + } + + if linuxdoUser.Id == 0 { + return nil, errors.New("invalid user info returned") + } + + return &linuxdoUser, nil +} + +func LinuxdoOAuth(c *gin.Context) { + session := sessions.Default(c) + + errorCode := c.Query("error") + if errorCode != "" { + errorDescription := c.Query("error_description") + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": errorDescription, + }) + return + } + + state := c.Query("state") + if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "state is empty or not same", + }) + return + } + + username := session.Get("username") + if username != nil { + LinuxDoBind(c) + return + } + + if !common.LinuxDOOAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 Linux DO 登录以及注册", + }) + return + } + + code := c.Query("code") + linuxdoUser, err := getLinuxdoUserInfoByCode(code, c) + if err != nil { + common.ApiError(c, err) + return + } + + user := model.User{ + LinuxDOId: strconv.Itoa(linuxdoUser.Id), + } + + // Check if user exists + if model.IsLinuxDOIdAlreadyTaken(user.LinuxDOId) { + err := user.FillUserByLinuxDOId() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + if user.Id == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已注销", + }) + return + } + } else { + if common.RegisterEnabled { + if linuxdoUser.TrustLevel >= common.LinuxDOMinimumTrustLevel { + user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1) + user.DisplayName = linuxdoUser.Name + user.Role = common.RoleCommonUser + user.Status = common.UserStatusEnabled + + affCode := session.Get("aff") + inviterId := 0 + if affCode != nil { + inviterId, _ = model.GetUserIdByAffCode(affCode.(string)) + } + + if err := user.Insert(inviterId); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Linux DO 信任等级未达到管理员设置的最低信任等级", + }) + return + } + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员关闭了新用户注册", + }) + return + } + } + + if user.Status != common.UserStatusEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "用户已被封禁", + "success": false, + }) + return + } + + setupLogin(&user, c) +} diff --git a/controller/log.go b/controller/log.go new file mode 100644 index 0000000000000000000000000000000000000000..da9bca468a2f39a4e90b9d15c24efd2bea1485a6 --- /dev/null +++ b/controller/log.go @@ -0,0 +1,169 @@ +package controller + +import ( + "net/http" + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +func GetAllLogs(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + logType, _ := strconv.Atoi(c.Query("type")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + username := c.Query("username") + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + channel, _ := strconv.Atoi(c.Query("channel")) + group := c.Query("group") + logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(logs) + common.ApiSuccess(c, pageInfo) + return +} + +func GetUserLogs(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + userId := c.GetInt("id") + logType, _ := strconv.Atoi(c.Query("type")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + group := c.Query("group") + logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(logs) + common.ApiSuccess(c, pageInfo) + return +} + +func SearchAllLogs(c *gin.Context) { + keyword := c.Query("keyword") + logs, err := model.SearchAllLogs(keyword) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": logs, + }) + return +} + +func SearchUserLogs(c *gin.Context) { + keyword := c.Query("keyword") + userId := c.GetInt("id") + logs, err := model.SearchUserLogs(userId, keyword) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": logs, + }) + return +} + +func GetLogByKey(c *gin.Context) { + key := c.Query("key") + logs, err := model.GetLogByKey(key) + if err != nil { + c.JSON(200, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(200, gin.H{ + "success": true, + "message": "", + "data": logs, + }) +} + +func GetLogsStat(c *gin.Context) { + logType, _ := strconv.Atoi(c.Query("type")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + tokenName := c.Query("token_name") + username := c.Query("username") + modelName := c.Query("model_name") + channel, _ := strconv.Atoi(c.Query("channel")) + group := c.Query("group") + stat := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group) + //tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, "") + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "quota": stat.Quota, + "rpm": stat.Rpm, + "tpm": stat.Tpm, + }, + }) + return +} + +func GetLogsSelfStat(c *gin.Context) { + username := c.GetString("username") + logType, _ := strconv.Atoi(c.Query("type")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + channel, _ := strconv.Atoi(c.Query("channel")) + group := c.Query("group") + quotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group) + //tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, tokenName) + c.JSON(200, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "quota": quotaNum.Quota, + "rpm": quotaNum.Rpm, + "tpm": quotaNum.Tpm, + //"token": tokenNum, + }, + }) + return +} + +func DeleteHistoryLogs(c *gin.Context) { + targetTimestamp, _ := strconv.ParseInt(c.Query("target_timestamp"), 10, 64) + if targetTimestamp == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "target timestamp is required", + }) + return + } + count, err := model.DeleteOldLog(c.Request.Context(), targetTimestamp, 100) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": count, + }) + return +} diff --git a/controller/midjourney.go b/controller/midjourney.go new file mode 100644 index 0000000000000000000000000000000000000000..c480c12bb92ae4230385b4be4194d4a040dac31d --- /dev/null +++ b/controller/midjourney.go @@ -0,0 +1,296 @@ +package controller + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-gonic/gin" +) + +func UpdateMidjourneyTaskBulk() { + //imageModel := "midjourney" + ctx := context.TODO() + for { + time.Sleep(time.Duration(15) * time.Second) + + tasks := model.GetAllUnFinishTasks() + if len(tasks) == 0 { + continue + } + + logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks))) + taskChannelM := make(map[int][]string) + taskM := make(map[string]*model.Midjourney) + nullTaskIds := make([]int, 0) + for _, task := range tasks { + if task.MjId == "" { + // 统计失败的未完成任务 + nullTaskIds = append(nullTaskIds, task.Id) + continue + } + taskM[task.MjId] = task + taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], task.MjId) + } + if len(nullTaskIds) > 0 { + err := model.MjBulkUpdateByTaskIds(nullTaskIds, map[string]any{ + "status": "FAILURE", + "progress": "100%", + }) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err)) + } else { + logger.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds)) + } + } + if len(taskChannelM) == 0 { + continue + } + + for channelId, taskIds := range taskChannelM { + logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds))) + if len(taskIds) == 0 { + continue + } + midjourneyChannel, err := model.CacheGetChannel(channelId) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err)) + err := model.MjBulkUpdate(taskIds, map[string]any{ + "fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId), + "status": "FAILURE", + "progress": "100%", + }) + if err != nil { + logger.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err)) + } + continue + } + requestUrl := fmt.Sprintf("%s/mj/task/list-by-condition", *midjourneyChannel.BaseURL) + + body, _ := json.Marshal(map[string]any{ + "ids": taskIds, + }) + req, err := http.NewRequest("POST", requestUrl, bytes.NewBuffer(body)) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("Get Task error: %v", err)) + continue + } + // 设置超时时间 + timeout := time.Second * 15 + ctx, cancel := context.WithTimeout(context.Background(), timeout) + // 使用带有超时的 context 创建新的请求 + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("mj-api-secret", midjourneyChannel.Key) + resp, err := service.GetHttpClient().Do(req) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err)) + continue + } + if resp.StatusCode != http.StatusOK { + logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode)) + continue + } + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("Get Task parse body error: %v", err)) + continue + } + var responseItems []dto.MidjourneyDto + err = json.Unmarshal(responseBody, &responseItems) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody))) + continue + } + resp.Body.Close() + req.Body.Close() + cancel() + + for _, responseItem := range responseItems { + task := taskM[responseItem.MjId] + + useTime := (time.Now().UnixNano() / int64(time.Millisecond)) - task.SubmitTime + // 如果时间超过一小时,且进度不是100%,则认为任务失败 + if useTime > 3600000 && task.Progress != "100%" { + responseItem.FailReason = "上游任务超时(超过1小时)" + responseItem.Status = "FAILURE" + } + if !checkMjTaskNeedUpdate(task, responseItem) { + continue + } + task.Code = 1 + task.Progress = responseItem.Progress + task.PromptEn = responseItem.PromptEn + task.State = responseItem.State + task.SubmitTime = responseItem.SubmitTime + task.StartTime = responseItem.StartTime + task.FinishTime = responseItem.FinishTime + task.ImageUrl = responseItem.ImageUrl + task.Status = responseItem.Status + task.FailReason = responseItem.FailReason + if responseItem.Properties != nil { + propertiesStr, _ := json.Marshal(responseItem.Properties) + task.Properties = string(propertiesStr) + } + if responseItem.Buttons != nil { + buttonStr, _ := json.Marshal(responseItem.Buttons) + task.Buttons = string(buttonStr) + } + // 映射 VideoUrl + task.VideoUrl = responseItem.VideoUrl + + // 映射 VideoUrls - 将数组序列化为 JSON 字符串 + if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 { + videoUrlsStr, err := json.Marshal(responseItem.VideoUrls) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err)) + task.VideoUrls = "[]" // 失败时设置为空数组 + } else { + task.VideoUrls = string(videoUrlsStr) + } + } else { + task.VideoUrls = "" // 空值时清空字段 + } + + shouldReturnQuota := false + if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") { + logger.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason) + task.Progress = "100%" + if task.Quota != 0 { + shouldReturnQuota = true + } + } + err = task.Update() + if err != nil { + logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error()) + } else { + if shouldReturnQuota { + err = model.IncreaseUserQuota(task.UserId, task.Quota, false) + if err != nil { + logger.LogError(ctx, "fail to increase user quota: "+err.Error()) + } + logContent := fmt.Sprintf("构图失败 %s,补偿 %s", task.MjId, logger.LogQuota(task.Quota)) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + } + } + } + } +} + +func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto) bool { + if oldTask.Code != 1 { + return true + } + if oldTask.Progress != newTask.Progress { + return true + } + if oldTask.PromptEn != newTask.PromptEn { + return true + } + if oldTask.State != newTask.State { + return true + } + if oldTask.SubmitTime != newTask.SubmitTime { + return true + } + if oldTask.StartTime != newTask.StartTime { + return true + } + if oldTask.FinishTime != newTask.FinishTime { + return true + } + if oldTask.ImageUrl != newTask.ImageUrl { + return true + } + if oldTask.Status != newTask.Status { + return true + } + if oldTask.FailReason != newTask.FailReason { + return true + } + if oldTask.FinishTime != newTask.FinishTime { + return true + } + if oldTask.Progress != "100%" && newTask.FailReason != "" { + return true + } + // 检查 VideoUrl 是否需要更新 + if oldTask.VideoUrl != newTask.VideoUrl { + return true + } + // 检查 VideoUrls 是否需要更新 + if newTask.VideoUrls != nil && len(newTask.VideoUrls) > 0 { + newVideoUrlsStr, _ := json.Marshal(newTask.VideoUrls) + if oldTask.VideoUrls != string(newVideoUrlsStr) { + return true + } + } else if oldTask.VideoUrls != "" { + // 如果新数据没有 VideoUrls 但旧数据有,需要更新(清空) + return true + } + + return false +} + +func GetAllMidjourney(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + + // 解析其他查询参数 + queryParams := model.TaskQueryParams{ + ChannelID: c.Query("channel_id"), + MjID: c.Query("mj_id"), + StartTimestamp: c.Query("start_timestamp"), + EndTimestamp: c.Query("end_timestamp"), + } + + items := model.GetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) + total := model.CountAllTasks(queryParams) + + if setting.MjForwardUrlEnabled { + for i, midjourney := range items { + midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId + items[i] = midjourney + } + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(items) + common.ApiSuccess(c, pageInfo) +} + +func GetUserMidjourney(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + + userId := c.GetInt("id") + + queryParams := model.TaskQueryParams{ + MjID: c.Query("mj_id"), + StartTimestamp: c.Query("start_timestamp"), + EndTimestamp: c.Query("end_timestamp"), + } + + items := model.GetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) + total := model.CountAllUserTask(userId, queryParams) + + if setting.MjForwardUrlEnabled { + for i, midjourney := range items { + midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId + items[i] = midjourney + } + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(items) + common.ApiSuccess(c, pageInfo) +} diff --git a/controller/misc.go b/controller/misc.go new file mode 100644 index 0000000000000000000000000000000000000000..70415137a2258ae062502d75914e8b966e7695c1 --- /dev/null +++ b/controller/misc.go @@ -0,0 +1,342 @@ +package controller + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/console_setting" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-gonic/gin" +) + +func TestStatus(c *gin.Context) { + err := model.PingDB() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "success": false, + "message": "数据库连接失败", + }) + return + } + // 获取HTTP统计信息 + httpStats := middleware.GetStats() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Server is running", + "http_stats": httpStats, + }) + return +} + +func GetStatus(c *gin.Context) { + + cs := console_setting.GetConsoleSetting() + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + + passkeySetting := system_setting.GetPasskeySettings() + legalSetting := system_setting.GetLegalSettings() + + data := gin.H{ + "version": common.Version, + "start_time": common.StartTime, + "email_verification": common.EmailVerificationEnabled, + "github_oauth": common.GitHubOAuthEnabled, + "github_client_id": common.GitHubClientId, + "discord_oauth": system_setting.GetDiscordSettings().Enabled, + "discord_client_id": system_setting.GetDiscordSettings().ClientId, + "linuxdo_oauth": common.LinuxDOOAuthEnabled, + "linuxdo_client_id": common.LinuxDOClientId, + "linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel, + "telegram_oauth": common.TelegramOAuthEnabled, + "telegram_bot_name": common.TelegramBotName, + "system_name": common.SystemName, + "logo": common.Logo, + "footer_html": common.Footer, + "wechat_qrcode": common.WeChatAccountQRCodeImageURL, + "wechat_login": common.WeChatAuthEnabled, + "server_address": system_setting.ServerAddress, + "turnstile_check": common.TurnstileCheckEnabled, + "turnstile_site_key": common.TurnstileSiteKey, + "top_up_link": common.TopUpLink, + "docs_link": operation_setting.GetGeneralSetting().DocsLink, + "quota_per_unit": common.QuotaPerUnit, + // 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type + "display_in_currency": operation_setting.IsCurrencyDisplay(), + "quota_display_type": operation_setting.GetQuotaDisplayType(), + "custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol, + "custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate, + "enable_batch_update": common.BatchUpdateEnabled, + "enable_drawing": common.DrawingEnabled, + "enable_task": common.TaskEnabled, + "enable_data_export": common.DataExportEnabled, + "data_export_default_time": common.DataExportDefaultTime, + "default_collapse_sidebar": common.DefaultCollapseSidebar, + "mj_notify_enabled": setting.MjNotifyEnabled, + "chats": setting.Chats, + "demo_site_enabled": operation_setting.DemoSiteEnabled, + "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, + "default_use_auto_group": setting.DefaultUseAutoGroup, + + "usd_exchange_rate": operation_setting.USDExchangeRate, + "price": operation_setting.Price, + "stripe_unit_price": setting.StripeUnitPrice, + + // 面板启用开关 + "api_info_enabled": cs.ApiInfoEnabled, + "uptime_kuma_enabled": cs.UptimeKumaEnabled, + "announcements_enabled": cs.AnnouncementsEnabled, + "faq_enabled": cs.FAQEnabled, + + // 模块管理配置 + "HeaderNavModules": common.OptionMap["HeaderNavModules"], + "SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"], + + "oidc_enabled": system_setting.GetOIDCSettings().Enabled, + "oidc_client_id": system_setting.GetOIDCSettings().ClientId, + "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, + "passkey_login": passkeySetting.Enabled, + "passkey_display_name": passkeySetting.RPDisplayName, + "passkey_rp_id": passkeySetting.RPID, + "passkey_origins": passkeySetting.Origins, + "passkey_allow_insecure": passkeySetting.AllowInsecureOrigin, + "passkey_user_verification": passkeySetting.UserVerification, + "passkey_attachment": passkeySetting.AttachmentPreference, + "setup": constant.Setup, + "user_agreement_enabled": legalSetting.UserAgreement != "", + "privacy_policy_enabled": legalSetting.PrivacyPolicy != "", + } + + // 根据启用状态注入可选内容 + if cs.ApiInfoEnabled { + data["api_info"] = console_setting.GetApiInfo() + } + if cs.AnnouncementsEnabled { + data["announcements"] = console_setting.GetAnnouncements() + } + if cs.FAQEnabled { + data["faq"] = console_setting.GetFAQ() + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": data, + }) + return +} + +func GetNotice(c *gin.Context) { + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": common.OptionMap["Notice"], + }) + return +} + +func GetAbout(c *gin.Context) { + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": common.OptionMap["About"], + }) + return +} + +func GetUserAgreement(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": system_setting.GetLegalSettings().UserAgreement, + }) + return +} + +func GetPrivacyPolicy(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": system_setting.GetLegalSettings().PrivacyPolicy, + }) + return +} + +func GetMidjourney(c *gin.Context) { + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": common.OptionMap["Midjourney"], + }) + return +} + +func GetHomePageContent(c *gin.Context) { + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": common.OptionMap["HomePageContent"], + }) + return +} + +func SendEmailVerification(c *gin.Context) { + email := c.Query("email") + if err := common.Validate.Var(email, "required,email"); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + parts := strings.Split(email, "@") + if len(parts) != 2 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的邮箱地址", + }) + return + } + localPart := parts[0] + domainPart := parts[1] + if common.EmailDomainRestrictionEnabled { + allowed := false + for _, domain := range common.EmailDomainWhitelist { + if domainPart == domain { + allowed = true + break + } + } + if !allowed { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "The administrator has enabled the email domain name whitelist, and your email address is not allowed due to special symbols or it's not in the whitelist.", + }) + return + } + } + if common.EmailAliasRestrictionEnabled { + containsSpecialSymbols := strings.Contains(localPart, "+") || strings.Contains(localPart, ".") + if containsSpecialSymbols { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员已启用邮箱地址别名限制,您的邮箱地址由于包含特殊符号而被拒绝。", + }) + return + } + } + + if model.IsEmailAlreadyTaken(email) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "邮箱地址已被占用", + }) + return + } + code := common.GenerateVerificationCode(6) + common.RegisterVerificationCodeWithKey(email, code, common.EmailVerificationPurpose) + subject := fmt.Sprintf("%s邮箱验证邮件", common.SystemName) + content := fmt.Sprintf("

您好,你正在进行%s邮箱验证。

"+ + "

您的验证码为: %s

"+ + "

验证码 %d 分钟内有效,如果不是本人操作,请忽略。

", common.SystemName, code, common.VerificationValidMinutes) + err := common.SendEmail(subject, email, content) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func SendPasswordResetEmail(c *gin.Context) { + email := c.Query("email") + if err := common.Validate.Var(email, "required,email"); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + if !model.IsEmailAlreadyTaken(email) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该邮箱地址未注册", + }) + return + } + code := common.GenerateVerificationCode(0) + common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose) + link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code) + subject := fmt.Sprintf("%s密码重置", common.SystemName) + content := fmt.Sprintf("

您好,你正在进行%s密码重置。

"+ + "

点击 此处 进行密码重置。

"+ + "

如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:
%s

"+ + "

重置链接 %d 分钟内有效,如果不是本人操作,请忽略。

", common.SystemName, link, link, common.VerificationValidMinutes) + err := common.SendEmail(subject, email, content) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +type PasswordResetRequest struct { + Email string `json:"email"` + Token string `json:"token"` +} + +func ResetPassword(c *gin.Context) { + var req PasswordResetRequest + err := json.NewDecoder(c.Request.Body).Decode(&req) + if req.Email == "" || req.Token == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + if !common.VerifyCodeWithKey(req.Email, req.Token, common.PasswordResetPurpose) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "重置链接非法或已过期", + }) + return + } + password := common.GenerateVerificationCode(12) + err = model.ResetUserPasswordByEmail(req.Email, password) + if err != nil { + common.ApiError(c, err) + return + } + common.DeleteKey(req.Email, common.PasswordResetPurpose) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": password, + }) + return +} diff --git a/controller/missing_models.go b/controller/missing_models.go new file mode 100644 index 0000000000000000000000000000000000000000..eddd8699d7d9fb4b58b5228d491847418614e3de --- /dev/null +++ b/controller/missing_models.go @@ -0,0 +1,28 @@ +package controller + +import ( + "net/http" + + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +// GetMissingModels returns the list of model names that are referenced by channels +// but do not have corresponding records in the models meta table. +// This helps administrators quickly discover models that need configuration. +func GetMissingModels(c *gin.Context) { + missing, err := model.GetMissingModels() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": missing, + }) +} diff --git a/controller/model.go b/controller/model.go new file mode 100644 index 0000000000000000000000000000000000000000..c2409fc007807927b2f9fefac1128c68124cf606 --- /dev/null +++ b/controller/model.go @@ -0,0 +1,288 @@ +package controller + +import ( + "fmt" + "net/http" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay" + "github.com/QuantumNous/new-api/relay/channel/ai360" + "github.com/QuantumNous/new-api/relay/channel/lingyiwanwu" + "github.com/QuantumNous/new-api/relay/channel/minimax" + "github.com/QuantumNous/new-api/relay/channel/moonshot" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/gin-gonic/gin" + "github.com/samber/lo" +) + +// https://platform.openai.com/docs/api-reference/models/list + +var openAIModels []dto.OpenAIModels +var openAIModelsMap map[string]dto.OpenAIModels +var channelId2Models map[int][]string + +func init() { + // https://platform.openai.com/docs/models/model-endpoint-compatibility + for i := 0; i < constant.APITypeDummy; i++ { + if i == constant.APITypeAIProxyLibrary { + continue + } + adaptor := relay.GetAdaptor(i) + channelName := adaptor.GetChannelName() + modelNames := adaptor.GetModelList() + for _, modelName := range modelNames { + openAIModels = append(openAIModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: channelName, + }) + } + } + for _, modelName := range ai360.ModelList { + openAIModels = append(openAIModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: ai360.ChannelName, + }) + } + for _, modelName := range moonshot.ModelList { + openAIModels = append(openAIModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: moonshot.ChannelName, + }) + } + for _, modelName := range lingyiwanwu.ModelList { + openAIModels = append(openAIModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: lingyiwanwu.ChannelName, + }) + } + for _, modelName := range minimax.ModelList { + openAIModels = append(openAIModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: minimax.ChannelName, + }) + } + for modelName, _ := range constant.MidjourneyModel2Action { + openAIModels = append(openAIModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: "midjourney", + }) + } + openAIModelsMap = make(map[string]dto.OpenAIModels) + for _, aiModel := range openAIModels { + openAIModelsMap[aiModel.Id] = aiModel + } + channelId2Models = make(map[int][]string) + for i := 1; i <= constant.ChannelTypeDummy; i++ { + apiType, success := common.ChannelType2APIType(i) + if !success || apiType == constant.APITypeAIProxyLibrary { + continue + } + meta := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: i, + }} + adaptor := relay.GetAdaptor(apiType) + adaptor.Init(meta) + channelId2Models[i] = adaptor.GetModelList() + } + openAIModels = lo.UniqBy(openAIModels, func(m dto.OpenAIModels) string { + return m.Id + }) +} + +func ListModels(c *gin.Context, modelType int) { + userOpenAiModels := make([]dto.OpenAIModels, 0) + + acceptUnsetRatioModel := operation_setting.SelfUseModeEnabled + if !acceptUnsetRatioModel { + userId := c.GetInt("id") + if userId > 0 { + userSettings, _ := model.GetUserSetting(userId, false) + if userSettings.AcceptUnsetRatioModel { + acceptUnsetRatioModel = true + } + } + } + + modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) + if modelLimitEnable { + s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit) + var tokenModelLimit map[string]bool + if ok { + tokenModelLimit = s.(map[string]bool) + } else { + tokenModelLimit = map[string]bool{} + } + for allowModel, _ := range tokenModelLimit { + if !acceptUnsetRatioModel { + _, _, exist := ratio_setting.GetModelRatioOrPrice(allowModel) + if !exist { + continue + } + } + if oaiModel, ok := openAIModelsMap[allowModel]; ok { + oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(allowModel) + userOpenAiModels = append(userOpenAiModels, oaiModel) + } else { + userOpenAiModels = append(userOpenAiModels, dto.OpenAIModels{ + Id: allowModel, + Object: "model", + Created: 1626777600, + OwnedBy: "custom", + SupportedEndpointTypes: model.GetModelSupportEndpointTypes(allowModel), + }) + } + } + } else { + userId := c.GetInt("id") + userGroup, err := model.GetUserGroup(userId, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "get user group failed", + }) + return + } + group := userGroup + tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup) + if tokenGroup != "" { + group = tokenGroup + } + var models []string + if tokenGroup == "auto" { + for _, autoGroup := range service.GetUserAutoGroup(userGroup) { + groupModels := model.GetGroupEnabledModels(autoGroup) + for _, g := range groupModels { + if !common.StringsContains(models, g) { + models = append(models, g) + } + } + } + } else { + models = model.GetGroupEnabledModels(group) + } + for _, modelName := range models { + if !acceptUnsetRatioModel { + _, _, exist := ratio_setting.GetModelRatioOrPrice(modelName) + if !exist { + continue + } + } + if oaiModel, ok := openAIModelsMap[modelName]; ok { + oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(modelName) + userOpenAiModels = append(userOpenAiModels, oaiModel) + } else { + userOpenAiModels = append(userOpenAiModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: "custom", + SupportedEndpointTypes: model.GetModelSupportEndpointTypes(modelName), + }) + } + } + } + + switch modelType { + case constant.ChannelTypeAnthropic: + useranthropicModels := make([]dto.AnthropicModel, len(userOpenAiModels)) + for i, model := range userOpenAiModels { + useranthropicModels[i] = dto.AnthropicModel{ + ID: model.Id, + CreatedAt: time.Unix(int64(model.Created), 0).UTC().Format(time.RFC3339), + DisplayName: model.Id, + Type: "model", + } + } + c.JSON(200, gin.H{ + "data": useranthropicModels, + "first_id": useranthropicModels[0].ID, + "has_more": false, + "last_id": useranthropicModels[len(useranthropicModels)-1].ID, + }) + case constant.ChannelTypeGemini: + userGeminiModels := make([]dto.GeminiModel, len(userOpenAiModels)) + for i, model := range userOpenAiModels { + userGeminiModels[i] = dto.GeminiModel{ + Name: model.Id, + DisplayName: model.Id, + } + } + c.JSON(200, gin.H{ + "models": userGeminiModels, + "nextPageToken": nil, + }) + default: + c.JSON(200, gin.H{ + "success": true, + "data": userOpenAiModels, + "object": "list", + }) + } +} + +func ChannelListModels(c *gin.Context) { + c.JSON(200, gin.H{ + "success": true, + "data": openAIModels, + }) +} + +func DashboardListModels(c *gin.Context) { + c.JSON(200, gin.H{ + "success": true, + "data": channelId2Models, + }) +} + +func EnabledListModels(c *gin.Context) { + c.JSON(200, gin.H{ + "success": true, + "data": model.GetEnabledModels(), + }) +} + +func RetrieveModel(c *gin.Context, modelType int) { + modelId := c.Param("model") + if aiModel, ok := openAIModelsMap[modelId]; ok { + switch modelType { + case constant.ChannelTypeAnthropic: + c.JSON(200, dto.AnthropicModel{ + ID: aiModel.Id, + CreatedAt: time.Unix(int64(aiModel.Created), 0).UTC().Format(time.RFC3339), + DisplayName: aiModel.Id, + Type: "model", + }) + default: + c.JSON(200, aiModel) + } + } else { + openAIError := dto.OpenAIError{ + Message: fmt.Sprintf("The model '%s' does not exist", modelId), + Type: "invalid_request_error", + Param: "model", + Code: "model_not_found", + } + c.JSON(200, gin.H{ + "error": openAIError, + }) + } +} diff --git a/controller/model_meta.go b/controller/model_meta.go new file mode 100644 index 0000000000000000000000000000000000000000..fd3626442a450677526197b691728e30ef5c8f44 --- /dev/null +++ b/controller/model_meta.go @@ -0,0 +1,330 @@ +package controller + +import ( + "encoding/json" + "sort" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +// GetAllModelsMeta 获取模型列表(分页) +func GetAllModelsMeta(c *gin.Context) { + + pageInfo := common.GetPageQuery(c) + modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + // 批量填充附加字段,提升列表接口性能 + enrichModels(modelsMeta) + var total int64 + model.DB.Model(&model.Model{}).Count(&total) + + // 统计供应商计数(全部数据,不受分页影响) + vendorCounts, _ := model.GetVendorModelCounts() + + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(modelsMeta) + common.ApiSuccess(c, gin.H{ + "items": modelsMeta, + "total": total, + "page": pageInfo.GetPage(), + "page_size": pageInfo.GetPageSize(), + "vendor_counts": vendorCounts, + }) +} + +// SearchModelsMeta 搜索模型列表 +func SearchModelsMeta(c *gin.Context) { + + keyword := c.Query("keyword") + vendor := c.Query("vendor") + pageInfo := common.GetPageQuery(c) + + modelsMeta, total, err := model.SearchModels(keyword, vendor, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + // 批量填充附加字段,提升列表接口性能 + enrichModels(modelsMeta) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(modelsMeta) + common.ApiSuccess(c, pageInfo) +} + +// GetModelMeta 根据 ID 获取单条模型信息 +func GetModelMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + var m model.Model + if err := model.DB.First(&m, id).Error; err != nil { + common.ApiError(c, err) + return + } + enrichModels([]*model.Model{&m}) + common.ApiSuccess(c, &m) +} + +// CreateModelMeta 新建模型 +func CreateModelMeta(c *gin.Context) { + var m model.Model + if err := c.ShouldBindJSON(&m); err != nil { + common.ApiError(c, err) + return + } + if m.ModelName == "" { + common.ApiErrorMsg(c, "模型名称不能为空") + return + } + // 名称冲突检查 + if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "模型名称已存在") + return + } + + if err := m.Insert(); err != nil { + common.ApiError(c, err) + return + } + model.RefreshPricing() + common.ApiSuccess(c, &m) +} + +// UpdateModelMeta 更新模型 +func UpdateModelMeta(c *gin.Context) { + statusOnly := c.Query("status_only") == "true" + + var m model.Model + if err := c.ShouldBindJSON(&m); err != nil { + common.ApiError(c, err) + return + } + if m.Id == 0 { + common.ApiErrorMsg(c, "缺少模型 ID") + return + } + + if statusOnly { + // 只更新状态,防止误清空其他字段 + if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil { + common.ApiError(c, err) + return + } + } else { + // 名称冲突检查 + if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "模型名称已存在") + return + } + + if err := m.Update(); err != nil { + common.ApiError(c, err) + return + } + } + model.RefreshPricing() + common.ApiSuccess(c, &m) +} + +// DeleteModelMeta 删除模型 +func DeleteModelMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + if err := model.DB.Delete(&model.Model{}, id).Error; err != nil { + common.ApiError(c, err) + return + } + model.RefreshPricing() + common.ApiSuccess(c, nil) +} + +// enrichModels 批量填充附加信息:端点、渠道、分组、计费类型,避免 N+1 查询 +func enrichModels(models []*model.Model) { + if len(models) == 0 { + return + } + + // 1) 拆分精确与规则匹配 + exactNames := make([]string, 0) + exactIdx := make(map[string][]int) // modelName -> indices in models + ruleIndices := make([]int, 0) + for i, m := range models { + if m == nil { + continue + } + if m.NameRule == model.NameRuleExact { + exactNames = append(exactNames, m.ModelName) + exactIdx[m.ModelName] = append(exactIdx[m.ModelName], i) + } else { + ruleIndices = append(ruleIndices, i) + } + } + + // 2) 批量查询精确模型的绑定渠道 + channelsByModel, _ := model.GetBoundChannelsByModelsMap(exactNames) + + // 3) 精确模型:端点从缓存、渠道批量映射、分组/计费类型从缓存 + for name, indices := range exactIdx { + chs := channelsByModel[name] + for _, idx := range indices { + mm := models[idx] + if mm.Endpoints == "" { + eps := model.GetModelSupportEndpointTypes(mm.ModelName) + if b, err := json.Marshal(eps); err == nil { + mm.Endpoints = string(b) + } + } + mm.BoundChannels = chs + mm.EnableGroups = model.GetModelEnableGroups(mm.ModelName) + mm.QuotaTypes = model.GetModelQuotaTypes(mm.ModelName) + } + } + + if len(ruleIndices) == 0 { + return + } + + // 4) 一次性读取定价缓存,内存匹配所有规则模型 + pricings := model.GetPricing() + + // 为全部规则模型收集匹配名集合、端点并集、分组并集、配额集合 + matchedNamesByIdx := make(map[int][]string) + endpointSetByIdx := make(map[int]map[constant.EndpointType]struct{}) + groupSetByIdx := make(map[int]map[string]struct{}) + quotaSetByIdx := make(map[int]map[int]struct{}) + + for _, p := range pricings { + for _, idx := range ruleIndices { + mm := models[idx] + var matched bool + switch mm.NameRule { + case model.NameRulePrefix: + matched = strings.HasPrefix(p.ModelName, mm.ModelName) + case model.NameRuleSuffix: + matched = strings.HasSuffix(p.ModelName, mm.ModelName) + case model.NameRuleContains: + matched = strings.Contains(p.ModelName, mm.ModelName) + } + if !matched { + continue + } + matchedNamesByIdx[idx] = append(matchedNamesByIdx[idx], p.ModelName) + + es := endpointSetByIdx[idx] + if es == nil { + es = make(map[constant.EndpointType]struct{}) + endpointSetByIdx[idx] = es + } + for _, et := range p.SupportedEndpointTypes { + es[et] = struct{}{} + } + + gs := groupSetByIdx[idx] + if gs == nil { + gs = make(map[string]struct{}) + groupSetByIdx[idx] = gs + } + for _, g := range p.EnableGroup { + gs[g] = struct{}{} + } + + qs := quotaSetByIdx[idx] + if qs == nil { + qs = make(map[int]struct{}) + quotaSetByIdx[idx] = qs + } + qs[p.QuotaType] = struct{}{} + } + } + + // 5) 汇总所有匹配到的模型名称,批量查询一次渠道 + allMatchedSet := make(map[string]struct{}) + for _, names := range matchedNamesByIdx { + for _, n := range names { + allMatchedSet[n] = struct{}{} + } + } + allMatched := make([]string, 0, len(allMatchedSet)) + for n := range allMatchedSet { + allMatched = append(allMatched, n) + } + matchedChannelsByModel, _ := model.GetBoundChannelsByModelsMap(allMatched) + + // 6) 回填每个规则模型的并集信息 + for _, idx := range ruleIndices { + mm := models[idx] + + // 端点并集 -> 序列化 + if es, ok := endpointSetByIdx[idx]; ok && mm.Endpoints == "" { + eps := make([]constant.EndpointType, 0, len(es)) + for et := range es { + eps = append(eps, et) + } + if b, err := json.Marshal(eps); err == nil { + mm.Endpoints = string(b) + } + } + + // 分组并集 + if gs, ok := groupSetByIdx[idx]; ok { + groups := make([]string, 0, len(gs)) + for g := range gs { + groups = append(groups, g) + } + mm.EnableGroups = groups + } + + // 配额类型集合(保持去重并排序) + if qs, ok := quotaSetByIdx[idx]; ok { + arr := make([]int, 0, len(qs)) + for k := range qs { + arr = append(arr, k) + } + sort.Ints(arr) + mm.QuotaTypes = arr + } + + // 渠道并集 + names := matchedNamesByIdx[idx] + channelSet := make(map[string]model.BoundChannel) + for _, n := range names { + for _, ch := range matchedChannelsByModel[n] { + key := ch.Name + "_" + strconv.Itoa(ch.Type) + channelSet[key] = ch + } + } + if len(channelSet) > 0 { + chs := make([]model.BoundChannel, 0, len(channelSet)) + for _, ch := range channelSet { + chs = append(chs, ch) + } + mm.BoundChannels = chs + } + + // 匹配信息 + mm.MatchedModels = names + mm.MatchedCount = len(names) + } +} diff --git a/controller/model_sync.go b/controller/model_sync.go new file mode 100644 index 0000000000000000000000000000000000000000..e321ee0c57a60082b5d0058c31b1893aa5103f9b --- /dev/null +++ b/controller/model_sync.go @@ -0,0 +1,604 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// 上游地址 +const ( + upstreamModelsURL = "https://basellm.github.io/llm-metadata/api/newapi/models.json" + upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json" +) + +func normalizeLocale(locale string) (string, bool) { + l := strings.ToLower(strings.TrimSpace(locale)) + switch l { + case "en", "zh", "ja": + return l, true + default: + return "", false + } +} + +func getUpstreamBase() string { + return common.GetEnvOrDefaultString("SYNC_UPSTREAM_BASE", "https://basellm.github.io/llm-metadata") +} + +func getUpstreamURLs(locale string) (modelsURL, vendorsURL string) { + base := strings.TrimRight(getUpstreamBase(), "/") + if l, ok := normalizeLocale(locale); ok && l != "" { + return fmt.Sprintf("%s/api/i18n/%s/newapi/models.json", base, l), + fmt.Sprintf("%s/api/i18n/%s/newapi/vendors.json", base, l) + } + return fmt.Sprintf("%s/api/newapi/models.json", base), fmt.Sprintf("%s/api/newapi/vendors.json", base) +} + +type upstreamEnvelope[T any] struct { + Success bool `json:"success"` + Message string `json:"message"` + Data []T `json:"data"` +} + +type upstreamModel struct { + Description string `json:"description"` + Endpoints json.RawMessage `json:"endpoints"` + Icon string `json:"icon"` + ModelName string `json:"model_name"` + NameRule int `json:"name_rule"` + Status int `json:"status"` + Tags string `json:"tags"` + VendorName string `json:"vendor_name"` +} + +type upstreamVendor struct { + Description string `json:"description"` + Icon string `json:"icon"` + Name string `json:"name"` + Status int `json:"status"` +} + +var ( + etagCache = make(map[string]string) + bodyCache = make(map[string][]byte) + cacheMutex sync.RWMutex +) + +type overwriteField struct { + ModelName string `json:"model_name"` + Fields []string `json:"fields"` +} + +type syncRequest struct { + Overwrite []overwriteField `json:"overwrite"` + Locale string `json:"locale"` +} + +func newHTTPClient() *http.Client { + timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 10) + dialer := &net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second} + transport := &http.Transport{ + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: time.Duration(timeoutSec) * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second, + } + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + if strings.HasSuffix(host, "github.io") { + if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil { + return conn, nil + } + return dialer.DialContext(ctx, "tcp6", addr) + } + return dialer.DialContext(ctx, network, addr) + } + return &http.Client{Transport: transport} +} + +var httpClient = newHTTPClient() + +func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error { + var lastErr error + attempts := common.GetEnvOrDefault("SYNC_HTTP_RETRY", 3) + if attempts < 1 { + attempts = 1 + } + baseDelay := 200 * time.Millisecond + maxMB := common.GetEnvOrDefault("SYNC_HTTP_MAX_MB", 10) + maxBytes := int64(maxMB) << 20 + for attempt := 0; attempt < attempts; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + // ETag conditional request + cacheMutex.RLock() + if et := etagCache[url]; et != "" { + req.Header.Set("If-None-Match", et) + } + cacheMutex.RUnlock() + + resp, err := httpClient.Do(req) + if err != nil { + lastErr = err + // backoff with jitter + sleep := baseDelay * time.Duration(1< id + vendorIDCache := make(map[string]int) + + for _, name := range missing { + up, ok := modelByName[name] + if !ok { + skipped = append(skipped, name) + continue + } + + // 若本地已存在且设置为不同步,则跳过(极端情况:缺失列表与本地状态不同步时) + var existing model.Model + if err := model.DB.Where("model_name = ?", name).First(&existing).Error; err == nil { + if existing.SyncOfficial == 0 { + skipped = append(skipped, name) + continue + } + } + + // 确保 vendor 存在 + vendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors) + + // 创建模型 + mi := &model.Model{ + ModelName: name, + Description: up.Description, + Icon: up.Icon, + Tags: up.Tags, + VendorID: vendorID, + Status: chooseStatus(up.Status, 1), + NameRule: up.NameRule, + } + if err := mi.Insert(); err == nil { + createdModels++ + createdList = append(createdList, name) + } else { + skipped = append(skipped, name) + } + } + + // 4) 处理可选覆盖(更新本地已有模型的差异字段) + if len(req.Overwrite) > 0 { + // vendorIDCache 已用于创建阶段,可复用 + for _, ow := range req.Overwrite { + up, ok := modelByName[ow.ModelName] + if !ok { + continue + } + var local model.Model + if err := model.DB.Where("model_name = ?", ow.ModelName).First(&local).Error; err != nil { + continue + } + + // 跳过被禁用官方同步的模型 + if local.SyncOfficial == 0 { + continue + } + + // 映射 vendor + newVendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors) + + // 应用字段覆盖(事务) + _ = model.DB.Transaction(func(tx *gorm.DB) error { + needUpdate := false + if containsField(ow.Fields, "description") { + local.Description = up.Description + needUpdate = true + } + if containsField(ow.Fields, "icon") { + local.Icon = up.Icon + needUpdate = true + } + if containsField(ow.Fields, "tags") { + local.Tags = up.Tags + needUpdate = true + } + if containsField(ow.Fields, "vendor") { + local.VendorID = newVendorID + needUpdate = true + } + if containsField(ow.Fields, "name_rule") { + local.NameRule = up.NameRule + needUpdate = true + } + if containsField(ow.Fields, "status") { + local.Status = chooseStatus(up.Status, local.Status) + needUpdate = true + } + if !needUpdate { + return nil + } + if err := tx.Save(&local).Error; err != nil { + return err + } + updatedModels++ + updatedList = append(updatedList, ow.ModelName) + return nil + }) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "created_models": createdModels, + "created_vendors": createdVendors, + "updated_models": updatedModels, + "skipped_models": skipped, + "created_list": createdList, + "updated_list": updatedList, + "source": gin.H{ + "locale": req.Locale, + "models_url": modelsURL, + "vendors_url": vendorsURL, + }, + }, + }) +} + +func containsField(fields []string, key string) bool { + key = strings.ToLower(strings.TrimSpace(key)) + for _, f := range fields { + if strings.ToLower(strings.TrimSpace(f)) == key { + return true + } + } + return false +} + +func coalesce(a, b string) string { + if strings.TrimSpace(a) != "" { + return a + } + return b +} + +func chooseStatus(primary, fallback int) int { + if primary == 0 && fallback != 0 { + return fallback + } + if primary != 0 { + return primary + } + return 1 +} + +// SyncUpstreamPreview 预览上游与本地的差异(仅用于弹窗选择) +func SyncUpstreamPreview(c *gin.Context) { + // 1) 拉取上游数据 + timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15) + ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second) + defer cancel() + + locale := c.Query("locale") + modelsURL, vendorsURL := getUpstreamURLs(locale) + + var vendorsEnv upstreamEnvelope[upstreamVendor] + var modelsEnv upstreamEnvelope[upstreamModel] + var fetchErr error + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _ = fetchJSON(ctx, vendorsURL, &vendorsEnv) + }() + go func() { + defer wg.Done() + if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil { + fetchErr = err + } + }() + wg.Wait() + if fetchErr != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}}) + return + } + + vendorByName := make(map[string]upstreamVendor) + for _, v := range vendorsEnv.Data { + if v.Name != "" { + vendorByName[v.Name] = v + } + } + modelByName := make(map[string]upstreamModel) + upstreamNames := make([]string, 0, len(modelsEnv.Data)) + for _, m := range modelsEnv.Data { + if m.ModelName != "" { + modelByName[m.ModelName] = m + upstreamNames = append(upstreamNames, m.ModelName) + } + } + + // 2) 本地已有模型 + var locals []model.Model + if len(upstreamNames) > 0 { + _ = model.DB.Where("model_name IN ? AND sync_official <> 0", upstreamNames).Find(&locals).Error + } + + // 本地 vendor 名称映射 + vendorIdSet := make(map[int]struct{}) + for _, m := range locals { + if m.VendorID != 0 { + vendorIdSet[m.VendorID] = struct{}{} + } + } + vendorIDs := make([]int, 0, len(vendorIdSet)) + for id := range vendorIdSet { + vendorIDs = append(vendorIDs, id) + } + idToVendorName := make(map[int]string) + if len(vendorIDs) > 0 { + var dbVendors []model.Vendor + _ = model.DB.Where("id IN ?", vendorIDs).Find(&dbVendors).Error + for _, v := range dbVendors { + idToVendorName[v.Id] = v.Name + } + } + + // 3) 缺失且上游存在的模型 + missingList, _ := model.GetMissingModels() + var missing []string + for _, name := range missingList { + if _, ok := modelByName[name]; ok { + missing = append(missing, name) + } + } + + // 4) 计算冲突字段 + type conflictField struct { + Field string `json:"field"` + Local interface{} `json:"local"` + Upstream interface{} `json:"upstream"` + } + type conflictItem struct { + ModelName string `json:"model_name"` + Fields []conflictField `json:"fields"` + } + + var conflicts []conflictItem + for _, local := range locals { + up, ok := modelByName[local.ModelName] + if !ok { + continue + } + fields := make([]conflictField, 0, 6) + if strings.TrimSpace(local.Description) != strings.TrimSpace(up.Description) { + fields = append(fields, conflictField{Field: "description", Local: local.Description, Upstream: up.Description}) + } + if strings.TrimSpace(local.Icon) != strings.TrimSpace(up.Icon) { + fields = append(fields, conflictField{Field: "icon", Local: local.Icon, Upstream: up.Icon}) + } + if strings.TrimSpace(local.Tags) != strings.TrimSpace(up.Tags) { + fields = append(fields, conflictField{Field: "tags", Local: local.Tags, Upstream: up.Tags}) + } + // vendor 对比使用名称 + localVendor := idToVendorName[local.VendorID] + if strings.TrimSpace(localVendor) != strings.TrimSpace(up.VendorName) { + fields = append(fields, conflictField{Field: "vendor", Local: localVendor, Upstream: up.VendorName}) + } + if local.NameRule != up.NameRule { + fields = append(fields, conflictField{Field: "name_rule", Local: local.NameRule, Upstream: up.NameRule}) + } + if local.Status != chooseStatus(up.Status, local.Status) { + fields = append(fields, conflictField{Field: "status", Local: local.Status, Upstream: up.Status}) + } + if len(fields) > 0 { + conflicts = append(conflicts, conflictItem{ModelName: local.ModelName, Fields: fields}) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "missing": missing, + "conflicts": conflicts, + "source": gin.H{ + "locale": locale, + "models_url": modelsURL, + "vendors_url": vendorsURL, + }, + }, + }) +} diff --git a/controller/oidc.go b/controller/oidc.go new file mode 100644 index 0000000000000000000000000000000000000000..ac49f84e1f39a1c8674577e4dc3ce7f83bb707a7 --- /dev/null +++ b/controller/oidc.go @@ -0,0 +1,228 @@ +package controller + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type OidcResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + +type OidcUser struct { + OpenID string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Picture string `json:"picture"` +} + +func getOidcUserInfoByCode(code string) (*OidcUser, error) { + if code == "" { + return nil, errors.New("无效的参数") + } + + values := url.Values{} + values.Set("client_id", system_setting.GetOIDCSettings().ClientId) + values.Set("client_secret", system_setting.GetOIDCSettings().ClientSecret) + values.Set("code", code) + values.Set("grant_type", "authorization_code") + values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress)) + formData := values.Encode() + req, err := http.NewRequest("POST", system_setting.GetOIDCSettings().TokenEndpoint, strings.NewReader(formData)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + client := http.Client{ + Timeout: 5 * time.Second, + } + res, err := client.Do(req) + if err != nil { + common.SysLog(err.Error()) + return nil, errors.New("无法连接至 OIDC 服务器,请稍后重试!") + } + defer res.Body.Close() + var oidcResponse OidcResponse + err = json.NewDecoder(res.Body).Decode(&oidcResponse) + if err != nil { + return nil, err + } + + if oidcResponse.AccessToken == "" { + common.SysLog("OIDC 获取 Token 失败,请检查设置!") + return nil, errors.New("OIDC 获取 Token 失败,请检查设置!") + } + + req, err = http.NewRequest("GET", system_setting.GetOIDCSettings().UserInfoEndpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+oidcResponse.AccessToken) + res2, err := client.Do(req) + if err != nil { + common.SysLog(err.Error()) + return nil, errors.New("无法连接至 OIDC 服务器,请稍后重试!") + } + defer res2.Body.Close() + if res2.StatusCode != http.StatusOK { + common.SysLog("OIDC 获取用户信息失败!请检查设置!") + return nil, errors.New("OIDC 获取用户信息失败!请检查设置!") + } + + var oidcUser OidcUser + err = json.NewDecoder(res2.Body).Decode(&oidcUser) + if err != nil { + return nil, err + } + if oidcUser.OpenID == "" || oidcUser.Email == "" { + common.SysLog("OIDC 获取用户信息为空!请检查设置!") + return nil, errors.New("OIDC 获取用户信息为空!请检查设置!") + } + return &oidcUser, nil +} + +func OidcAuth(c *gin.Context) { + session := sessions.Default(c) + state := c.Query("state") + if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "state is empty or not same", + }) + return + } + username := session.Get("username") + if username != nil { + OidcBind(c) + return + } + if !system_setting.GetOIDCSettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 OIDC 登录以及注册", + }) + return + } + code := c.Query("code") + oidcUser, err := getOidcUserInfoByCode(code) + if err != nil { + common.ApiError(c, err) + return + } + user := model.User{ + OidcId: oidcUser.OpenID, + } + if model.IsOidcIdAlreadyTaken(user.OidcId) { + err := user.FillUserByOidcId() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + if common.RegisterEnabled { + user.Email = oidcUser.Email + if oidcUser.PreferredUsername != "" { + user.Username = oidcUser.PreferredUsername + } else { + user.Username = "oidc_" + strconv.Itoa(model.GetMaxUserId()+1) + } + if oidcUser.Name != "" { + user.DisplayName = oidcUser.Name + } else { + user.DisplayName = "OIDC User" + } + err := user.Insert(0) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员关闭了新用户注册", + }) + return + } + } + + if user.Status != common.UserStatusEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "用户已被封禁", + "success": false, + }) + return + } + setupLogin(&user, c) +} + +func OidcBind(c *gin.Context) { + if !system_setting.GetOIDCSettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 OIDC 登录以及注册", + }) + return + } + code := c.Query("code") + oidcUser, err := getOidcUserInfoByCode(code) + if err != nil { + common.ApiError(c, err) + return + } + user := model.User{ + OidcId: oidcUser.OpenID, + } + if model.IsOidcIdAlreadyTaken(user.OidcId) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该 OIDC 账户已被绑定", + }) + return + } + session := sessions.Default(c) + id := session.Get("id") + // id := c.GetInt("id") // critical bug! + user.Id = id.(int) + err = user.FillUserById() + if err != nil { + common.ApiError(c, err) + return + } + user.OidcId = oidcUser.OpenID + err = user.Update(false) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "bind", + }) + return +} diff --git a/controller/option.go b/controller/option.go new file mode 100644 index 0000000000000000000000000000000000000000..89b2fc4d52a4a1a1fe22ff616a218fbf76b6c55f --- /dev/null +++ b/controller/option.go @@ -0,0 +1,223 @@ +package controller + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/console_setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-gonic/gin" +) + +func GetOptions(c *gin.Context) { + var options []*model.Option + common.OptionMapRWMutex.Lock() + for k, v := range common.OptionMap { + if strings.HasSuffix(k, "Token") || strings.HasSuffix(k, "Secret") || strings.HasSuffix(k, "Key") { + continue + } + options = append(options, &model.Option{ + Key: k, + Value: common.Interface2String(v), + }) + } + common.OptionMapRWMutex.Unlock() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": options, + }) + return +} + +type OptionUpdateRequest struct { + Key string `json:"key"` + Value any `json:"value"` +} + +func UpdateOption(c *gin.Context) { + var option OptionUpdateRequest + err := json.NewDecoder(c.Request.Body).Decode(&option) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + switch option.Value.(type) { + case bool: + option.Value = common.Interface2String(option.Value.(bool)) + case float64: + option.Value = common.Interface2String(option.Value.(float64)) + case int: + option.Value = common.Interface2String(option.Value.(int)) + default: + option.Value = fmt.Sprintf("%v", option.Value) + } + switch option.Key { + case "GitHubOAuthEnabled": + if option.Value == "true" && common.GitHubClientId == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用 GitHub OAuth,请先填入 GitHub Client Id 以及 GitHub Client Secret!", + }) + return + } + case "discord.enabled": + if option.Value == "true" && system_setting.GetDiscordSettings().ClientId == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用 Discord OAuth,请先填入 Discord Client Id 以及 Discord Client Secret!", + }) + return + } + case "oidc.enabled": + if option.Value == "true" && system_setting.GetOIDCSettings().ClientId == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用 OIDC 登录,请先填入 OIDC Client Id 以及 OIDC Client Secret!", + }) + return + } + case "LinuxDOOAuthEnabled": + if option.Value == "true" && common.LinuxDOClientId == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用 LinuxDO OAuth,请先填入 LinuxDO Client Id 以及 LinuxDO Client Secret!", + }) + return + } + case "EmailDomainRestrictionEnabled": + if option.Value == "true" && len(common.EmailDomainWhitelist) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用邮箱域名限制,请先填入限制的邮箱域名!", + }) + return + } + case "WeChatAuthEnabled": + if option.Value == "true" && common.WeChatServerAddress == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用微信登录,请先填入微信登录相关配置信息!", + }) + return + } + case "TurnstileCheckEnabled": + if option.Value == "true" && common.TurnstileSiteKey == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!", + }) + + return + } + case "TelegramOAuthEnabled": + if option.Value == "true" && common.TelegramBotToken == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用 Telegram OAuth,请先填入 Telegram Bot Token!", + }) + return + } + case "GroupRatio": + err = ratio_setting.CheckGroupRatio(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "ImageRatio": + err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "图片倍率设置失败: " + err.Error(), + }) + return + } + case "AudioRatio": + err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "音频倍率设置失败: " + err.Error(), + }) + return + } + case "AudioCompletionRatio": + err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "音频补全倍率设置失败: " + err.Error(), + }) + return + } + case "ModelRequestRateLimitGroup": + err = setting.CheckModelRequestRateLimitGroup(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "console_setting.api_info": + err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "console_setting.announcements": + err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "console_setting.faq": + err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "console_setting.uptime_kuma_groups": + err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } + err = model.UpdateOption(option.Key, option.Value.(string)) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} diff --git a/controller/passkey.go b/controller/passkey.go new file mode 100644 index 0000000000000000000000000000000000000000..a2cc53699bd58f38df817ff14cacf3950795b109 --- /dev/null +++ b/controller/passkey.go @@ -0,0 +1,497 @@ +package controller + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + passkeysvc "github.com/QuantumNous/new-api/service/passkey" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/protocol" + webauthnlib "github.com/go-webauthn/webauthn/webauthn" +) + +func PasskeyRegisterBegin(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { + common.ApiError(c, err) + return + } + if errors.Is(err, model.ErrPasskeyNotFound) { + credential = nil + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + var options []webauthnlib.RegistrationOption + if credential != nil { + descriptor := credential.ToWebAuthnCredential().Descriptor() + options = append(options, webauthnlib.WithExclusions([]protocol.CredentialDescriptor{descriptor})) + } + + creation, sessionData, err := wa.BeginRegistration(waUser, options...) + if err != nil { + common.ApiError(c, err) + return + } + + if err := passkeysvc.SaveSessionData(c, passkeysvc.RegistrationSessionKey, sessionData); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "options": creation, + }, + }) +} + +func PasskeyRegisterFinish(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + credentialRecord, err := model.GetPasskeyByUserID(user.Id) + if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { + common.ApiError(c, err) + return + } + if errors.Is(err, model.ErrPasskeyNotFound) { + credentialRecord = nil + } + + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.RegistrationSessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord) + credential, err := wa.FinishRegistration(waUser, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + passkeyCredential := model.NewPasskeyCredentialFromWebAuthn(user.Id, credential) + if passkeyCredential == nil { + common.ApiErrorMsg(c, "无法创建 Passkey 凭证") + return + } + + if err := model.UpsertPasskeyCredential(passkeyCredential); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 注册成功", + }) +} + +func PasskeyDelete(c *gin.Context) { + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + if err := model.DeletePasskeyByUserID(user.Id); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 已解绑", + }) +} + +func PasskeyStatus(c *gin.Context) { + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if errors.Is(err, model.ErrPasskeyNotFound) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "enabled": false, + }, + }) + return + } + if err != nil { + common.ApiError(c, err) + return + } + + data := gin.H{ + "enabled": true, + "last_used_at": credential.LastUsedAt, + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": data, + }) +} + +func PasskeyLoginBegin(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + assertion, sessionData, err := wa.BeginDiscoverableLogin() + if err != nil { + common.ApiError(c, err) + return + } + + if err := passkeysvc.SaveSessionData(c, passkeysvc.LoginSessionKey, sessionData); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "options": assertion, + }, + }) +} + +func PasskeyLoginFinish(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.LoginSessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + handler := func(rawID, userHandle []byte) (webauthnlib.User, error) { + // 首先通过凭证ID查找用户 + credential, err := model.GetPasskeyByCredentialID(rawID) + if err != nil { + return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err) + } + + // 通过凭证获取用户 + user := &model.User{Id: credential.UserID} + if err := user.FillUserById(); err != nil { + return nil, fmt.Errorf("用户信息获取失败: %w", err) + } + + if user.Status != common.UserStatusEnabled { + return nil, errors.New("该用户已被禁用") + } + + if len(userHandle) > 0 { + userID, parseErr := strconv.Atoi(string(userHandle)) + if parseErr != nil { + // 记录异常但继续验证,因为某些客户端可能使用非数字格式 + common.SysLog(fmt.Sprintf("PasskeyLogin: userHandle parse error for credential, length: %d", len(userHandle))) + } else if userID != user.Id { + return nil, errors.New("用户句柄与凭证不匹配") + } + } + + return passkeysvc.NewWebAuthnUser(user, credential), nil + } + + waUser, credential, err := wa.FinishPasskeyLogin(handler, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + userWrapper, ok := waUser.(*passkeysvc.WebAuthnUser) + if !ok { + common.ApiErrorMsg(c, "Passkey 登录状态异常") + return + } + + modelUser := userWrapper.ModelUser() + if modelUser == nil { + common.ApiErrorMsg(c, "Passkey 登录状态异常") + return + } + + if modelUser.Status != common.UserStatusEnabled { + common.ApiErrorMsg(c, "该用户已被禁用") + return + } + + // 更新凭证信息 + updatedCredential := model.NewPasskeyCredentialFromWebAuthn(modelUser.Id, credential) + if updatedCredential == nil { + common.ApiErrorMsg(c, "Passkey 凭证更新失败") + return + } + now := time.Now() + updatedCredential.LastUsedAt = &now + if err := model.UpsertPasskeyCredential(updatedCredential); err != nil { + common.ApiError(c, err) + return + } + + setupLogin(modelUser, c) + return +} + +func AdminResetPasskey(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiErrorMsg(c, "无效的用户 ID") + return + } + + user := &model.User{Id: id} + if err := user.FillUserById(); err != nil { + common.ApiError(c, err) + return + } + + if _, err := model.GetPasskeyByUserID(user.Id); err != nil { + if errors.Is(err, model.ErrPasskeyNotFound) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + common.ApiError(c, err) + return + } + + if err := model.DeletePasskeyByUserID(user.Id); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 已重置", + }) +} + +func PasskeyVerifyBegin(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + assertion, sessionData, err := wa.BeginLogin(waUser) + if err != nil { + common.ApiError(c, err) + return + } + + if err := passkeysvc.SaveSessionData(c, passkeysvc.VerifySessionKey, sessionData); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "options": assertion, + }, + }) +} + +func PasskeyVerifyFinish(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + _, err = wa.FinishLogin(waUser, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + // 更新凭证的最后使用时间 + now := time.Now() + credential.LastUsedAt = &now + if err := model.UpsertPasskeyCredential(credential); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 验证成功", + }) +} + +func getSessionUser(c *gin.Context) (*model.User, error) { + session := sessions.Default(c) + idRaw := session.Get("id") + if idRaw == nil { + return nil, errors.New("未登录") + } + id, ok := idRaw.(int) + if !ok { + return nil, errors.New("无效的会话信息") + } + user := &model.User{Id: id} + if err := user.FillUserById(); err != nil { + return nil, err + } + if user.Status != common.UserStatusEnabled { + return nil, errors.New("该用户已被禁用") + } + return user, nil +} diff --git a/controller/playground.go b/controller/playground.go new file mode 100644 index 0000000000000000000000000000000000000000..342f47cf0edf5051c8b2244512ad65e9a208ac99 --- /dev/null +++ b/controller/playground.go @@ -0,0 +1,61 @@ +package controller + +import ( + "errors" + "fmt" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func Playground(c *gin.Context) { + var newAPIError *types.NewAPIError + + defer func() { + if newAPIError != nil { + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + }() + + useAccessToken := c.GetBool("use_access_token") + if useAccessToken { + newAPIError = types.NewError(errors.New("暂不支持使用 access token"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry()) + return + } + + group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) + modelName := c.GetString("original_model") + + userId := c.GetInt("id") + + // Write user context to ensure acceptUnsetRatio is available + userCache, err := model.GetUserCache(userId) + if err != nil { + newAPIError = types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) + return + } + userCache.WriteContext(c) + + tempToken := &model.Token{ + UserId: userId, + Name: fmt.Sprintf("playground-%s", group), + Group: group, + } + _ = middleware.SetupContextForToken(c, tempToken) + _, newAPIError = getChannel(c, group, modelName, 0) + if newAPIError != nil { + return + } + //middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model) + common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now()) + + Relay(c, types.RelayFormatOpenAI) +} diff --git a/controller/prefill_group.go b/controller/prefill_group.go new file mode 100644 index 0000000000000000000000000000000000000000..3c990daa0681f30ee5a4a3f15c1e75bcb515bc7f --- /dev/null +++ b/controller/prefill_group.go @@ -0,0 +1,90 @@ +package controller + +import ( + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +// GetPrefillGroups 获取预填组列表,可通过 ?type=xxx 过滤 +func GetPrefillGroups(c *gin.Context) { + groupType := c.Query("type") + groups, err := model.GetAllPrefillGroups(groupType) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, groups) +} + +// CreatePrefillGroup 创建新的预填组 +func CreatePrefillGroup(c *gin.Context) { + var g model.PrefillGroup + if err := c.ShouldBindJSON(&g); err != nil { + common.ApiError(c, err) + return + } + if g.Name == "" || g.Type == "" { + common.ApiErrorMsg(c, "组名称和类型不能为空") + return + } + // 创建前检查名称 + if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "组名称已存在") + return + } + + if err := g.Insert(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &g) +} + +// UpdatePrefillGroup 更新预填组 +func UpdatePrefillGroup(c *gin.Context) { + var g model.PrefillGroup + if err := c.ShouldBindJSON(&g); err != nil { + common.ApiError(c, err) + return + } + if g.Id == 0 { + common.ApiErrorMsg(c, "缺少组 ID") + return + } + // 名称冲突检查 + if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "组名称已存在") + return + } + + if err := g.Update(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &g) +} + +// DeletePrefillGroup 删除预填组 +func DeletePrefillGroup(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + if err := model.DeletePrefillGroupByID(id); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} diff --git a/controller/pricing.go b/controller/pricing.go new file mode 100644 index 0000000000000000000000000000000000000000..dd3f7edca37cd5fb719a112450806fe5ad8db402 --- /dev/null +++ b/controller/pricing.go @@ -0,0 +1,74 @@ +package controller + +import ( + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +func GetPricing(c *gin.Context) { + pricing := model.GetPricing() + userId, exists := c.Get("id") + usableGroup := map[string]string{} + groupRatio := map[string]float64{} + for s, f := range ratio_setting.GetGroupRatioCopy() { + groupRatio[s] = f + } + var group string + if exists { + user, err := model.GetUserCache(userId.(int)) + if err == nil { + group = user.Group + for g := range groupRatio { + ratio, ok := ratio_setting.GetGroupGroupRatio(group, g) + if ok { + groupRatio[g] = ratio + } + } + } + } + + usableGroup = service.GetUserUsableGroups(group) + // check groupRatio contains usableGroup + for group := range ratio_setting.GetGroupRatioCopy() { + if _, ok := usableGroup[group]; !ok { + delete(groupRatio, group) + } + } + + c.JSON(200, gin.H{ + "success": true, + "data": pricing, + "vendors": model.GetVendors(), + "group_ratio": groupRatio, + "usable_group": usableGroup, + "supported_endpoint": model.GetSupportedEndpointMap(), + "auto_groups": service.GetUserAutoGroup(group), + }) +} + +func ResetModelRatio(c *gin.Context) { + defaultStr := ratio_setting.DefaultModelRatio2JSONString() + err := model.UpdateOption("ModelRatio", defaultStr) + if err != nil { + c.JSON(200, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + err = ratio_setting.UpdateModelRatioByJSONString(defaultStr) + if err != nil { + c.JSON(200, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(200, gin.H{ + "success": true, + "message": "重置模型倍率成功", + }) +} diff --git a/controller/ratio_config.go b/controller/ratio_config.go new file mode 100644 index 0000000000000000000000000000000000000000..b9b9d479a116f3f2cbd0018a4942d27f23cd2299 --- /dev/null +++ b/controller/ratio_config.go @@ -0,0 +1,25 @@ +package controller + +import ( + "net/http" + + "github.com/QuantumNous/new-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +func GetRatioConfig(c *gin.Context) { + if !ratio_setting.IsExposeRatioEnabled() { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "倍率配置接口未启用", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": ratio_setting.GetExposedData(), + }) +} diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go new file mode 100644 index 0000000000000000000000000000000000000000..b8224b8163ab0d5a49d0135fa6c1e94221302bad --- /dev/null +++ b/controller/ratio_sync.go @@ -0,0 +1,540 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/logger" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +const ( + defaultTimeoutSeconds = 10 + defaultEndpoint = "/api/ratio_config" + maxConcurrentFetches = 8 + maxRatioConfigBytes = 10 << 20 // 10MB + floatEpsilon = 1e-9 +) + +func nearlyEqual(a, b float64) bool { + if a > b { + return a-b < floatEpsilon + } + return b-a < floatEpsilon +} + +func valuesEqual(a, b interface{}) bool { + af, aok := a.(float64) + bf, bok := b.(float64) + if aok && bok { + return nearlyEqual(af, bf) + } + return a == b +} + +var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"} + +type upstreamResult struct { + Name string `json:"name"` + Data map[string]any `json:"data,omitempty"` + Err string `json:"err,omitempty"` +} + +func FetchUpstreamRatios(c *gin.Context) { + var req dto.UpstreamRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) + return + } + + if req.Timeout <= 0 { + req.Timeout = defaultTimeoutSeconds + } + + var upstreams []dto.UpstreamDTO + + if len(req.Upstreams) > 0 { + for _, u := range req.Upstreams { + if strings.HasPrefix(u.BaseURL, "http") { + if u.Endpoint == "" { + u.Endpoint = defaultEndpoint + } + u.BaseURL = strings.TrimRight(u.BaseURL, "/") + upstreams = append(upstreams, u) + } + } + } else if len(req.ChannelIDs) > 0 { + intIds := make([]int, 0, len(req.ChannelIDs)) + for _, id64 := range req.ChannelIDs { + intIds = append(intIds, int(id64)) + } + dbChannels, err := model.GetChannelsByIds(intIds) + if err != nil { + logger.LogError(c.Request.Context(), "failed to query channels: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"}) + return + } + for _, ch := range dbChannels { + if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") { + upstreams = append(upstreams, dto.UpstreamDTO{ + ID: ch.Id, + Name: ch.Name, + BaseURL: strings.TrimRight(base, "/"), + Endpoint: "", + }) + } + } + } + + if len(upstreams) == 0 { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"}) + return + } + + var wg sync.WaitGroup + ch := make(chan upstreamResult, len(upstreams)) + + sem := make(chan struct{}, maxConcurrentFetches) + + dialer := &net.Dialer{Timeout: 10 * time.Second} + transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second} + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + // 对 github.io 优先尝试 IPv4,失败则回退 IPv6 + if strings.HasSuffix(host, "github.io") { + if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil { + return conn, nil + } + return dialer.DialContext(ctx, "tcp6", addr) + } + return dialer.DialContext(ctx, network, addr) + } + client := &http.Client{Transport: transport} + + for _, chn := range upstreams { + wg.Add(1) + go func(chItem dto.UpstreamDTO) { + defer wg.Done() + + sem <- struct{}{} + defer func() { <-sem }() + + endpoint := chItem.Endpoint + var fullURL string + if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { + fullURL = endpoint + } else { + if endpoint == "" { + endpoint = defaultEndpoint + } else if !strings.HasPrefix(endpoint, "/") { + endpoint = "/" + endpoint + } + fullURL = chItem.BaseURL + endpoint + } + + uniqueName := chItem.Name + if chItem.ID != 0 { + uniqueName = fmt.Sprintf("%s(%d)", chItem.Name, chItem.ID) + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second) + defer cancel() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + logger.LogWarn(c.Request.Context(), "build request failed: "+err.Error()) + ch <- upstreamResult{Name: uniqueName, Err: err.Error()} + return + } + + // 简单重试:最多 3 次,指数退避 + var resp *http.Response + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + resp, lastErr = client.Do(httpReq) + if lastErr == nil { + break + } + time.Sleep(time.Duration(200*(1< data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price + // type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式 + var body struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + Message string `json:"message"` + } + + if err := json.NewDecoder(limited).Decode(&body); err != nil { + logger.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error()) + ch <- upstreamResult{Name: uniqueName, Err: err.Error()} + return + } + + if !body.Success { + ch <- upstreamResult{Name: uniqueName, Err: body.Message} + return + } + + // 若 Data 为空,将继续按 type1 尝试解析(与多数静态 ratio_config 兼容) + + // 尝试按 type1 解析 + var type1Data map[string]any + if err := json.Unmarshal(body.Data, &type1Data); err == nil { + // 如果包含至少一个 ratioTypes 字段,则认为是 type1 + isType1 := false + for _, rt := range ratioTypes { + if _, ok := type1Data[rt]; ok { + isType1 = true + break + } + } + if isType1 { + ch <- upstreamResult{Name: uniqueName, Data: type1Data} + return + } + } + + // 如果不是 type1,则尝试按 type2 (/api/pricing) 解析 + var pricingItems []struct { + ModelName string `json:"model_name"` + QuotaType int `json:"quota_type"` + ModelRatio float64 `json:"model_ratio"` + ModelPrice float64 `json:"model_price"` + CompletionRatio float64 `json:"completion_ratio"` + } + if err := json.Unmarshal(body.Data, &pricingItems); err != nil { + logger.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error()) + ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"} + return + } + + modelRatioMap := make(map[string]float64) + completionRatioMap := make(map[string]float64) + modelPriceMap := make(map[string]float64) + + for _, item := range pricingItems { + if item.QuotaType == 1 { + modelPriceMap[item.ModelName] = item.ModelPrice + } else { + modelRatioMap[item.ModelName] = item.ModelRatio + // completionRatio 可能为 0,此时也直接赋值,保持与上游一致 + completionRatioMap[item.ModelName] = item.CompletionRatio + } + } + + converted := make(map[string]any) + + if len(modelRatioMap) > 0 { + ratioAny := make(map[string]any, len(modelRatioMap)) + for k, v := range modelRatioMap { + ratioAny[k] = v + } + converted["model_ratio"] = ratioAny + } + + if len(completionRatioMap) > 0 { + compAny := make(map[string]any, len(completionRatioMap)) + for k, v := range completionRatioMap { + compAny[k] = v + } + converted["completion_ratio"] = compAny + } + + if len(modelPriceMap) > 0 { + priceAny := make(map[string]any, len(modelPriceMap)) + for k, v := range modelPriceMap { + priceAny[k] = v + } + converted["model_price"] = priceAny + } + + ch <- upstreamResult{Name: uniqueName, Data: converted} + }(chn) + } + + wg.Wait() + close(ch) + + localData := ratio_setting.GetExposedData() + + var testResults []dto.TestResult + var successfulChannels []struct { + name string + data map[string]any + } + + for r := range ch { + if r.Err != "" { + testResults = append(testResults, dto.TestResult{ + Name: r.Name, + Status: "error", + Error: r.Err, + }) + } else { + testResults = append(testResults, dto.TestResult{ + Name: r.Name, + Status: "success", + }) + successfulChannels = append(successfulChannels, struct { + name string + data map[string]any + }{name: r.Name, data: r.Data}) + } + } + + differences := buildDifferences(localData, successfulChannels) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "differences": differences, + "test_results": testResults, + }, + }) +} + +func buildDifferences(localData map[string]any, successfulChannels []struct { + name string + data map[string]any +}) map[string]map[string]dto.DifferenceItem { + differences := make(map[string]map[string]dto.DifferenceItem) + + allModels := make(map[string]struct{}) + + for _, ratioType := range ratioTypes { + if localRatioAny, ok := localData[ratioType]; ok { + if localRatio, ok := localRatioAny.(map[string]float64); ok { + for modelName := range localRatio { + allModels[modelName] = struct{}{} + } + } + } + } + + for _, channel := range successfulChannels { + for _, ratioType := range ratioTypes { + if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok { + for modelName := range upstreamRatio { + allModels[modelName] = struct{}{} + } + } + } + } + + confidenceMap := make(map[string]map[string]bool) + + // 预处理阶段:检查pricing接口的可信度 + for _, channel := range successfulChannels { + confidenceMap[channel.name] = make(map[string]bool) + + modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any) + completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any) + + if hasModelRatio && hasCompletionRatio { + // 遍历所有模型,检查是否满足不可信条件 + for modelName := range allModels { + // 默认为可信 + confidenceMap[channel.name][modelName] = true + + // 检查是否满足不可信条件:model_ratio为37.5且completion_ratio为1 + if modelRatioVal, ok := modelRatios[modelName]; ok { + if completionRatioVal, ok := completionRatios[modelName]; ok { + // 转换为float64进行比较 + if modelRatioFloat, ok := modelRatioVal.(float64); ok { + if completionRatioFloat, ok := completionRatioVal.(float64); ok { + if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 { + confidenceMap[channel.name][modelName] = false + } + } + } + } + } + } + } else { + // 如果不是从pricing接口获取的数据,则全部标记为可信 + for modelName := range allModels { + confidenceMap[channel.name][modelName] = true + } + } + } + + for modelName := range allModels { + for _, ratioType := range ratioTypes { + var localValue interface{} = nil + if localRatioAny, ok := localData[ratioType]; ok { + if localRatio, ok := localRatioAny.(map[string]float64); ok { + if val, exists := localRatio[modelName]; exists { + localValue = val + } + } + } + + upstreamValues := make(map[string]interface{}) + confidenceValues := make(map[string]bool) + hasUpstreamValue := false + hasDifference := false + + for _, channel := range successfulChannels { + var upstreamValue interface{} = nil + + if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok { + if val, exists := upstreamRatio[modelName]; exists { + upstreamValue = val + hasUpstreamValue = true + + if localValue != nil && !valuesEqual(localValue, val) { + hasDifference = true + } else if valuesEqual(localValue, val) { + upstreamValue = "same" + } + } + } + if upstreamValue == nil && localValue == nil { + upstreamValue = "same" + } + + if localValue == nil && upstreamValue != nil && upstreamValue != "same" { + hasDifference = true + } + + upstreamValues[channel.name] = upstreamValue + + confidenceValues[channel.name] = confidenceMap[channel.name][modelName] + } + + shouldInclude := false + + if localValue != nil { + if hasDifference { + shouldInclude = true + } + } else { + if hasUpstreamValue { + shouldInclude = true + } + } + + if shouldInclude { + if differences[modelName] == nil { + differences[modelName] = make(map[string]dto.DifferenceItem) + } + differences[modelName][ratioType] = dto.DifferenceItem{ + Current: localValue, + Upstreams: upstreamValues, + Confidence: confidenceValues, + } + } + } + } + + channelHasDiff := make(map[string]bool) + for _, ratioMap := range differences { + for _, item := range ratioMap { + for chName, val := range item.Upstreams { + if val != nil && val != "same" { + channelHasDiff[chName] = true + } + } + } + } + + for modelName, ratioMap := range differences { + for ratioType, item := range ratioMap { + for chName := range item.Upstreams { + if !channelHasDiff[chName] { + delete(item.Upstreams, chName) + delete(item.Confidence, chName) + } + } + + allSame := true + for _, v := range item.Upstreams { + if v != "same" { + allSame = false + break + } + } + if len(item.Upstreams) == 0 || allSame { + delete(ratioMap, ratioType) + } else { + differences[modelName][ratioType] = item + } + } + + if len(ratioMap) == 0 { + delete(differences, modelName) + } + } + + return differences +} + +func GetSyncableChannels(c *gin.Context) { + channels, err := model.GetAllChannels(0, 0, true, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + var syncableChannels []dto.SyncableChannel + for _, channel := range channels { + if channel.GetBaseURL() != "" { + syncableChannels = append(syncableChannels, dto.SyncableChannel{ + ID: channel.Id, + Name: channel.Name, + BaseURL: channel.GetBaseURL(), + Status: channel.Status, + }) + } + } + + syncableChannels = append(syncableChannels, dto.SyncableChannel{ + ID: -100, + Name: "官方倍率预设", + BaseURL: "https://basellm.github.io", + Status: 1, + }) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": syncableChannels, + }) +} diff --git a/controller/redemption.go b/controller/redemption.go new file mode 100644 index 0000000000000000000000000000000000000000..945cefa353582acd9cabc79385289730b701eeef --- /dev/null +++ b/controller/redemption.go @@ -0,0 +1,195 @@ +package controller + +import ( + "errors" + "net/http" + "strconv" + "unicode/utf8" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +func GetAllRedemptions(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + redemptions, total, err := model.GetAllRedemptions(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(redemptions) + common.ApiSuccess(c, pageInfo) + return +} + +func SearchRedemptions(c *gin.Context) { + keyword := c.Query("keyword") + pageInfo := common.GetPageQuery(c) + redemptions, total, err := model.SearchRedemptions(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(redemptions) + common.ApiSuccess(c, pageInfo) + return +} + +func GetRedemption(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + redemption, err := model.GetRedemptionById(id) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": redemption, + }) + return +} + +func AddRedemption(c *gin.Context) { + redemption := model.Redemption{} + err := c.ShouldBindJSON(&redemption) + if err != nil { + common.ApiError(c, err) + return + } + if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "兑换码名称长度必须在1-20之间", + }) + return + } + if redemption.Count <= 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "兑换码个数必须大于0", + }) + return + } + if redemption.Count > 100 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "一次兑换码批量生成的个数不能大于 100", + }) + return + } + if err := validateExpiredTime(redemption.ExpiredTime); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + var keys []string + for i := 0; i < redemption.Count; i++ { + key := common.GetUUID() + cleanRedemption := model.Redemption{ + UserId: c.GetInt("id"), + Name: redemption.Name, + Key: key, + CreatedTime: common.GetTimestamp(), + Quota: redemption.Quota, + ExpiredTime: redemption.ExpiredTime, + } + err = cleanRedemption.Insert() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + "data": keys, + }) + return + } + keys = append(keys, key) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": keys, + }) + return +} + +func DeleteRedemption(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + err := model.DeleteRedemptionById(id) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func UpdateRedemption(c *gin.Context) { + statusOnly := c.Query("status_only") + redemption := model.Redemption{} + err := c.ShouldBindJSON(&redemption) + if err != nil { + common.ApiError(c, err) + return + } + cleanRedemption, err := model.GetRedemptionById(redemption.Id) + if err != nil { + common.ApiError(c, err) + return + } + if statusOnly == "" { + if err := validateExpiredTime(redemption.ExpiredTime); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + // If you add more fields, please also update redemption.Update() + cleanRedemption.Name = redemption.Name + cleanRedemption.Quota = redemption.Quota + cleanRedemption.ExpiredTime = redemption.ExpiredTime + } + if statusOnly != "" { + cleanRedemption.Status = redemption.Status + } + err = cleanRedemption.Update() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": cleanRedemption, + }) + return +} + +func DeleteInvalidRedemption(c *gin.Context) { + rows, err := model.DeleteInvalidRedemptions() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": rows, + }) + return +} + +func validateExpiredTime(expired int64) error { + if expired != 0 && expired < common.GetTimestamp() { + return errors.New("过期时间不能早于当前时间") + } + return nil +} diff --git a/controller/relay.go b/controller/relay.go new file mode 100644 index 0000000000000000000000000000000000000000..f8a233e995902e00d79457e53126d5bee18f0cff --- /dev/null +++ b/controller/relay.go @@ -0,0 +1,485 @@ +package controller + +import ( + "bytes" + "fmt" + "io" + "log" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/types" + + "github.com/bytedance/gopkg/util/gopool" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError { + var err *types.NewAPIError + switch info.RelayMode { + case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits: + err = relay.ImageHelper(c, info) + case relayconstant.RelayModeAudioSpeech: + fallthrough + case relayconstant.RelayModeAudioTranslation: + fallthrough + case relayconstant.RelayModeAudioTranscription: + err = relay.AudioHelper(c, info) + case relayconstant.RelayModeRerank: + err = relay.RerankHelper(c, info) + case relayconstant.RelayModeEmbeddings: + err = relay.EmbeddingHelper(c, info) + case relayconstant.RelayModeResponses: + err = relay.ResponsesHelper(c, info) + default: + err = relay.TextHelper(c, info) + } + return err +} + +func geminiRelayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError { + var err *types.NewAPIError + if strings.Contains(c.Request.URL.Path, "embed") { + err = relay.GeminiEmbeddingHandler(c, info) + } else { + err = relay.GeminiHelper(c, info) + } + return err +} + +func Relay(c *gin.Context, relayFormat types.RelayFormat) { + + requestId := c.GetString(common.RequestIdKey) + group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) + originalModel := common.GetContextKeyString(c, constant.ContextKeyOriginalModel) + + var ( + newAPIError *types.NewAPIError + ws *websocket.Conn + ) + + if relayFormat == types.RelayFormatOpenAIRealtime { + var err error + ws, err = upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()).ToOpenAIError()) + return + } + defer ws.Close() + } + + defer func() { + if newAPIError != nil { + logger.LogError(c, fmt.Sprintf("relay error: %s", newAPIError.Error())) + newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId)) + switch relayFormat { + case types.RelayFormatOpenAIRealtime: + helper.WssError(c, ws, newAPIError.ToOpenAIError()) + case types.RelayFormatClaude: + c.JSON(newAPIError.StatusCode, gin.H{ + "type": "error", + "error": newAPIError.ToClaudeError(), + }) + default: + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + } + }() + + request, err := helper.GetAndValidateRequest(c, relayFormat) + if err != nil { + newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest) + return + } + + relayInfo, err := relaycommon.GenRelayInfo(c, relayFormat, request, ws) + if err != nil { + newAPIError = types.NewError(err, types.ErrorCodeGenRelayInfoFailed) + return + } + + meta := request.GetTokenCountMeta() + + if setting.ShouldCheckPromptSensitive() { + contains, words := service.CheckSensitiveText(meta.CombineText) + if contains { + logger.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", "))) + newAPIError = types.NewError(err, types.ErrorCodeSensitiveWordsDetected) + return + } + } + + tokens, err := service.CountRequestToken(c, meta, relayInfo) + if err != nil { + newAPIError = types.NewError(err, types.ErrorCodeCountTokenFailed) + return + } + + relayInfo.SetPromptTokens(tokens) + + priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta) + if err != nil { + newAPIError = types.NewError(err, types.ErrorCodeModelPriceError) + return + } + + // common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta) + + if priceData.FreeModel { + logger.LogInfo(c, fmt.Sprintf("模型 %s 免费,跳过预扣费", relayInfo.OriginModelName)) + } else { + newAPIError = service.PreConsumeQuota(c, priceData.QuotaToPreConsume, relayInfo) + if newAPIError != nil { + return + } + } + + defer func() { + // Only return quota if downstream failed and quota was actually pre-consumed + if newAPIError != nil && relayInfo.FinalPreConsumedQuota != 0 { + service.ReturnPreConsumedQuota(c, relayInfo) + } + }() + + for i := 0; i <= common.RetryTimes; i++ { + channel, err := getChannel(c, group, originalModel, i) + if err != nil { + logger.LogError(c, err.Error()) + newAPIError = err + break + } + + addUsedChannel(c, channel.Id) + requestBody, _ := common.GetRequestBody(c) + c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) + + switch relayFormat { + case types.RelayFormatOpenAIRealtime: + newAPIError = relay.WssHelper(c, relayInfo) + case types.RelayFormatClaude: + newAPIError = relay.ClaudeHelper(c, relayInfo) + case types.RelayFormatGemini: + newAPIError = geminiRelayHandler(c, relayInfo) + default: + newAPIError = relayHandler(c, relayInfo) + } + + if newAPIError == nil { + return + } + + processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError) + + if !shouldRetry(c, newAPIError, common.RetryTimes-i) { + break + } + } + + useChannel := c.GetStringSlice("use_channel") + if len(useChannel) > 1 { + retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]")) + logger.LogInfo(c, retryLogStr) + } +} + +var upgrader = websocket.Upgrader{ + Subprotocols: []string{"realtime"}, // WS 握手支持的协议,如果有使用 Sec-WebSocket-Protocol,则必须在此声明对应的 Protocol TODO add other protocol + CheckOrigin: func(r *http.Request) bool { + return true // 允许跨域 + }, +} + +func addUsedChannel(c *gin.Context, channelId int) { + useChannel := c.GetStringSlice("use_channel") + useChannel = append(useChannel, fmt.Sprintf("%d", channelId)) + c.Set("use_channel", useChannel) +} + +func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*model.Channel, *types.NewAPIError) { + if retryCount == 0 { + autoBan := c.GetBool("auto_ban") + autoBanInt := 1 + if !autoBan { + autoBanInt = 0 + } + return &model.Channel{ + Id: c.GetInt("channel_id"), + Type: c.GetInt("channel_type"), + Name: c.GetString("channel_name"), + AutoBan: &autoBanInt, + }, nil + } + channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount) + if err != nil { + return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, originalModel, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) + } + if channel == nil { + return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, originalModel), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) + } + newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel) + if newAPIError != nil { + return nil, newAPIError + } + return channel, nil +} + +func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) bool { + if openaiErr == nil { + return false + } + if types.IsChannelError(openaiErr) { + return true + } + if types.IsSkipRetryError(openaiErr) { + return false + } + if retryTimes <= 0 { + return false + } + if _, ok := c.Get("specific_channel_id"); ok { + return false + } + if openaiErr.StatusCode == http.StatusTooManyRequests { + return true + } + if openaiErr.StatusCode == 307 { + return true + } + if openaiErr.StatusCode/100 == 5 { + // 超时不重试 + if openaiErr.StatusCode == 504 || openaiErr.StatusCode == 524 { + return false + } + return true + } + if openaiErr.StatusCode == http.StatusBadRequest { + return false + } + if openaiErr.StatusCode == 408 { + // azure处理超时不重试 + return false + } + if openaiErr.StatusCode/100 == 2 { + return false + } + return true +} + +func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) { + logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error())) + // 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况 + // do not use context to get channel info, there may be inconsistent channel info when processing asynchronously + if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan { + gopool.Go(func() { + service.DisableChannel(channelError, err.Error()) + }) + } + + if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) { + // 保存错误日志到mysql中 + userId := c.GetInt("id") + tokenName := c.GetString("token_name") + modelName := c.GetString("original_model") + tokenId := c.GetInt("token_id") + userGroup := c.GetString("group") + channelId := c.GetInt("channel_id") + other := make(map[string]interface{}) + if c.Request != nil && c.Request.URL != nil { + other["request_path"] = c.Request.URL.Path + } + other["error_type"] = err.GetErrorType() + other["error_code"] = err.GetErrorCode() + other["status_code"] = err.StatusCode + other["channel_id"] = channelId + other["channel_name"] = c.GetString("channel_name") + other["channel_type"] = c.GetInt("channel_type") + adminInfo := make(map[string]interface{}) + adminInfo["use_channel"] = c.GetStringSlice("use_channel") + isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey) + if isMultiKey { + adminInfo["is_multi_key"] = true + adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex) + } + other["admin_info"] = adminInfo + model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other) + } + +} + +func RelayMidjourney(c *gin.Context) { + relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatMjProxy, nil, nil) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "description": fmt.Sprintf("failed to generate relay info: %s", err.Error()), + "type": "upstream_error", + "code": 4, + }) + return + } + + var mjErr *dto.MidjourneyResponse + switch relayInfo.RelayMode { + case relayconstant.RelayModeMidjourneyNotify: + mjErr = relay.RelayMidjourneyNotify(c) + case relayconstant.RelayModeMidjourneyTaskFetch, relayconstant.RelayModeMidjourneyTaskFetchByCondition: + mjErr = relay.RelayMidjourneyTask(c, relayInfo.RelayMode) + case relayconstant.RelayModeMidjourneyTaskImageSeed: + mjErr = relay.RelayMidjourneyTaskImageSeed(c) + case relayconstant.RelayModeSwapFace: + mjErr = relay.RelaySwapFace(c, relayInfo) + default: + mjErr = relay.RelayMidjourneySubmit(c, relayInfo) + } + //err = relayMidjourneySubmit(c, relayMode) + log.Println(mjErr) + if mjErr != nil { + statusCode := http.StatusBadRequest + if mjErr.Code == 30 { + mjErr.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。" + statusCode = http.StatusTooManyRequests + } + c.JSON(statusCode, gin.H{ + "description": fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result), + "type": "upstream_error", + "code": mjErr.Code, + }) + channelId := c.GetInt("channel_id") + logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result))) + } +} + +func RelayNotImplemented(c *gin.Context) { + err := dto.OpenAIError{ + Message: "API not implemented", + Type: "new_api_error", + Param: "", + Code: "api_not_implemented", + } + c.JSON(http.StatusNotImplemented, gin.H{ + "error": err, + }) +} + +func RelayNotFound(c *gin.Context) { + err := dto.OpenAIError{ + Message: fmt.Sprintf("Invalid URL (%s %s)", c.Request.Method, c.Request.URL.Path), + Type: "invalid_request_error", + Param: "", + Code: "", + } + c.JSON(http.StatusNotFound, gin.H{ + "error": err, + }) +} + +func RelayTask(c *gin.Context) { + retryTimes := common.RetryTimes + channelId := c.GetInt("channel_id") + group := c.GetString("group") + originalModel := c.GetString("original_model") + c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)}) + relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil) + if err != nil { + return + } + taskErr := taskRelayHandler(c, relayInfo) + if taskErr == nil { + retryTimes = 0 + } + for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ { + channel, newAPIError := getChannel(c, group, originalModel, i) + if newAPIError != nil { + logger.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error())) + taskErr = service.TaskErrorWrapperLocal(newAPIError.Err, "get_channel_failed", http.StatusInternalServerError) + break + } + channelId = channel.Id + useChannel := c.GetStringSlice("use_channel") + useChannel = append(useChannel, fmt.Sprintf("%d", channelId)) + c.Set("use_channel", useChannel) + logger.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, i)) + //middleware.SetupContextForSelectedChannel(c, channel, originalModel) + + requestBody, _ := common.GetRequestBody(c) + c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) + taskErr = taskRelayHandler(c, relayInfo) + } + useChannel := c.GetStringSlice("use_channel") + if len(useChannel) > 1 { + retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]")) + logger.LogInfo(c, retryLogStr) + } + if taskErr != nil { + if taskErr.StatusCode == http.StatusTooManyRequests { + taskErr.Message = "当前分组上游负载已饱和,请稍后再试" + } + c.JSON(taskErr.StatusCode, taskErr) + } +} + +func taskRelayHandler(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dto.TaskError { + var err *dto.TaskError + switch relayInfo.RelayMode { + case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeVideoFetchByID: + err = relay.RelayTaskFetch(c, relayInfo.RelayMode) + default: + err = relay.RelayTaskSubmit(c, relayInfo) + } + return err +} + +func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError, retryTimes int) bool { + if taskErr == nil { + return false + } + if retryTimes <= 0 { + return false + } + if _, ok := c.Get("specific_channel_id"); ok { + return false + } + if taskErr.StatusCode == http.StatusTooManyRequests { + return true + } + if taskErr.StatusCode == 307 { + return true + } + if taskErr.StatusCode/100 == 5 { + // 超时不重试 + if taskErr.StatusCode == 504 || taskErr.StatusCode == 524 { + return false + } + return true + } + if taskErr.StatusCode == http.StatusBadRequest { + return false + } + if taskErr.StatusCode == 408 { + // azure处理超时不重试 + return false + } + if taskErr.LocalError { + return false + } + if taskErr.StatusCode/100 == 2 { + return false + } + return true +} diff --git a/controller/secure_verification.go b/controller/secure_verification.go new file mode 100644 index 0000000000000000000000000000000000000000..f30c259e629a9bae520a8f7c4fddf0cf5a0e4407 --- /dev/null +++ b/controller/secure_verification.go @@ -0,0 +1,314 @@ +package controller + +import ( + "fmt" + "net/http" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + passkeysvc "github.com/QuantumNous/new-api/service/passkey" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +const ( + // SecureVerificationSessionKey 安全验证的 session key + SecureVerificationSessionKey = "secure_verified_at" + // SecureVerificationTimeout 验证有效期(秒) + SecureVerificationTimeout = 300 // 5分钟 +) + +type UniversalVerifyRequest struct { + Method string `json:"method"` // "2fa" 或 "passkey" + Code string `json:"code,omitempty"` +} + +type VerificationStatusResponse struct { + Verified bool `json:"verified"` + ExpiresAt int64 `json:"expires_at,omitempty"` +} + +// UniversalVerify 通用验证接口 +// 支持 2FA 和 Passkey 验证,验证成功后在 session 中记录时间戳 +func UniversalVerify(c *gin.Context) { + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + var req UniversalVerifyRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, fmt.Errorf("参数错误: %v", err)) + return + } + + // 获取用户信息 + user := &model.User{Id: userId} + if err := user.FillUserById(); err != nil { + common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err)) + return + } + + if user.Status != common.UserStatusEnabled { + common.ApiError(c, fmt.Errorf("该用户已被禁用")) + return + } + + // 检查用户的验证方式 + twoFA, _ := model.GetTwoFAByUserId(userId) + has2FA := twoFA != nil && twoFA.IsEnabled + + passkey, passkeyErr := model.GetPasskeyByUserID(userId) + hasPasskey := passkeyErr == nil && passkey != nil + + if !has2FA && !hasPasskey { + common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey")) + return + } + + // 根据验证方式进行验证 + var verified bool + var verifyMethod string + + switch req.Method { + case "2fa": + if !has2FA { + common.ApiError(c, fmt.Errorf("用户未启用2FA")) + return + } + if req.Code == "" { + common.ApiError(c, fmt.Errorf("验证码不能为空")) + return + } + verified = validateTwoFactorAuth(twoFA, req.Code) + verifyMethod = "2FA" + + case "passkey": + if !hasPasskey { + common.ApiError(c, fmt.Errorf("用户未启用Passkey")) + return + } + // Passkey 验证需要先调用 PasskeyVerifyBegin 和 PasskeyVerifyFinish + // 这里只是验证 Passkey 验证流程是否已经完成 + // 实际上,前端应该先调用这两个接口,然后再调用本接口 + verified = true // Passkey 验证逻辑已在 PasskeyVerifyFinish 中完成 + verifyMethod = "Passkey" + + default: + common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method)) + return + } + + if !verified { + common.ApiError(c, fmt.Errorf("验证失败,请检查验证码")) + return + } + + // 验证成功,在 session 中记录时间戳 + session := sessions.Default(c) + now := time.Now().Unix() + session.Set(SecureVerificationSessionKey, now) + if err := session.Save(); err != nil { + common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err)) + return + } + + // 记录日志 + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("通用安全验证成功 (验证方式: %s)", verifyMethod)) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "验证成功", + "data": gin.H{ + "verified": true, + "expires_at": now + SecureVerificationTimeout, + }, + }) +} + +// GetVerificationStatus 获取验证状态 +func GetVerificationStatus(c *gin.Context) { + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: false, + }, + }) + return + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: false, + }, + }) + return + } + + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + // 验证已过期 + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: false, + }, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: true, + ExpiresAt: verifiedAt + SecureVerificationTimeout, + }, + }) +} + +// CheckSecureVerification 检查是否已通过安全验证 +// 返回 true 表示验证有效,false 表示需要重新验证 +func CheckSecureVerification(c *gin.Context) bool { + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + return false + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + return false + } + + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + // 验证已过期,清除 session + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + return false + } + + return true +} + +// PasskeyVerifyAndSetSession Passkey 验证完成后设置 session +// 这是一个辅助函数,供 PasskeyVerifyFinish 调用 +func PasskeyVerifyAndSetSession(c *gin.Context) { + session := sessions.Default(c) + now := time.Now().Unix() + session.Set(SecureVerificationSessionKey, now) + _ = session.Save() +} + +// PasskeyVerifyForSecure 用于安全验证的 Passkey 验证流程 +// 整合了 begin 和 finish 流程 +func PasskeyVerifyForSecure(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + user := &model.User{Id: userId} + if err := user.FillUserById(); err != nil { + common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err)) + return + } + + if user.Status != common.UserStatusEnabled { + common.ApiError(c, fmt.Errorf("该用户已被禁用")) + return + } + + credential, err := model.GetPasskeyByUserID(userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + _, err = wa.FinishLogin(waUser, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + // 更新凭证的最后使用时间 + now := time.Now() + credential.LastUsedAt = &now + if err := model.UpsertPasskeyCredential(credential); err != nil { + common.ApiError(c, err) + return + } + + // 验证成功,设置 session + PasskeyVerifyAndSetSession(c) + + // 记录日志 + model.RecordLog(userId, model.LogTypeSystem, "Passkey 安全验证成功") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 验证成功", + "data": gin.H{ + "verified": true, + "expires_at": time.Now().Unix() + SecureVerificationTimeout, + }, + }) +} diff --git a/controller/setup.go b/controller/setup.go new file mode 100644 index 0000000000000000000000000000000000000000..2f6a0c9beed159ae268205db4672ed22ef1d453f --- /dev/null +++ b/controller/setup.go @@ -0,0 +1,182 @@ +package controller + +import ( + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/gin-gonic/gin" +) + +type Setup struct { + Status bool `json:"status"` + RootInit bool `json:"root_init"` + DatabaseType string `json:"database_type"` +} + +type SetupRequest struct { + Username string `json:"username"` + Password string `json:"password"` + ConfirmPassword string `json:"confirmPassword"` + SelfUseModeEnabled bool `json:"SelfUseModeEnabled"` + DemoSiteEnabled bool `json:"DemoSiteEnabled"` +} + +func GetSetup(c *gin.Context) { + setup := Setup{ + Status: constant.Setup, + } + if constant.Setup { + c.JSON(200, gin.H{ + "success": true, + "data": setup, + }) + return + } + setup.RootInit = model.RootUserExists() + if common.UsingMySQL { + setup.DatabaseType = "mysql" + } + if common.UsingPostgreSQL { + setup.DatabaseType = "postgres" + } + if common.UsingSQLite { + setup.DatabaseType = "sqlite" + } + c.JSON(200, gin.H{ + "success": true, + "data": setup, + }) +} + +func PostSetup(c *gin.Context) { + // Check if setup is already completed + if constant.Setup { + c.JSON(200, gin.H{ + "success": false, + "message": "系统已经初始化完成", + }) + return + } + + // Check if root user already exists + rootExists := model.RootUserExists() + + var req SetupRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(200, gin.H{ + "success": false, + "message": "请求参数有误", + }) + return + } + + // If root doesn't exist, validate and create admin account + if !rootExists { + // Validate username length: max 12 characters to align with model.User validation + if len(req.Username) > 12 { + c.JSON(200, gin.H{ + "success": false, + "message": "用户名长度不能超过12个字符", + }) + return + } + // Validate password + if req.Password != req.ConfirmPassword { + c.JSON(200, gin.H{ + "success": false, + "message": "两次输入的密码不一致", + }) + return + } + + if len(req.Password) < 8 { + c.JSON(200, gin.H{ + "success": false, + "message": "密码长度至少为8个字符", + }) + return + } + + // Create root user + hashedPassword, err := common.Password2Hash(req.Password) + if err != nil { + c.JSON(200, gin.H{ + "success": false, + "message": "系统错误: " + err.Error(), + }) + return + } + rootUser := model.User{ + Username: req.Username, + Password: hashedPassword, + Role: common.RoleRootUser, + Status: common.UserStatusEnabled, + DisplayName: "Root User", + AccessToken: nil, + Quota: 100000000, + } + err = model.DB.Create(&rootUser).Error + if err != nil { + c.JSON(200, gin.H{ + "success": false, + "message": "创建管理员账号失败: " + err.Error(), + }) + return + } + } + + // Set operation modes + operation_setting.SelfUseModeEnabled = req.SelfUseModeEnabled + operation_setting.DemoSiteEnabled = req.DemoSiteEnabled + + // Save operation modes to database for persistence + err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled)) + if err != nil { + c.JSON(200, gin.H{ + "success": false, + "message": "保存自用模式设置失败: " + err.Error(), + }) + return + } + + err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled)) + if err != nil { + c.JSON(200, gin.H{ + "success": false, + "message": "保存演示站点模式设置失败: " + err.Error(), + }) + return + } + + // Update setup status + constant.Setup = true + + setup := model.Setup{ + Version: common.Version, + InitializedAt: time.Now().Unix(), + } + err = model.DB.Create(&setup).Error + if err != nil { + c.JSON(200, gin.H{ + "success": false, + "message": "系统初始化失败: " + err.Error(), + }) + return + } + + c.JSON(200, gin.H{ + "success": true, + "message": "系统初始化成功", + }) +} + +func boolToString(b bool) string { + if b { + return "true" + } + return "false" +} diff --git a/controller/swag_video.go b/controller/swag_video.go new file mode 100644 index 0000000000000000000000000000000000000000..68dd6345f60ca11403f9e570c2575dfd0a2b2b5d --- /dev/null +++ b/controller/swag_video.go @@ -0,0 +1,136 @@ +package controller + +import ( + "github.com/gin-gonic/gin" +) + +// VideoGenerations +// @Summary 生成视频 +// @Description 调用视频生成接口生成视频 +// @Description 支持多种视频生成服务: +// @Description - 可灵AI (Kling): https://app.klingai.com/cn/dev/document-api/apiReference/commonInfo +// @Description - 即梦 (Jimeng): https://www.volcengine.com/docs/85621/1538636 +// @Tags Video +// @Accept json +// @Produce json +// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)" +// @Param request body dto.VideoRequest true "视频生成请求参数" +// @Failure 400 {object} dto.OpenAIError "请求参数错误" +// @Failure 401 {object} dto.OpenAIError "未授权" +// @Failure 403 {object} dto.OpenAIError "无权限" +// @Failure 500 {object} dto.OpenAIError "服务器内部错误" +// @Router /v1/video/generations [post] +func VideoGenerations(c *gin.Context) { +} + +// VideoGenerationsTaskId +// @Summary 查询视频 +// @Description 根据任务ID查询视频生成任务的状态和结果 +// @Tags Video +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param task_id path string true "Task ID" +// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果" +// @Failure 400 {object} dto.OpenAIError "请求参数错误" +// @Failure 401 {object} dto.OpenAIError "未授权" +// @Failure 403 {object} dto.OpenAIError "无权限" +// @Failure 500 {object} dto.OpenAIError "服务器内部错误" +// @Router /v1/video/generations/{task_id} [get] +func VideoGenerationsTaskId(c *gin.Context) { +} + +// KlingText2VideoGenerations +// @Summary 可灵文生视频 +// @Description 调用可灵AI文生视频接口,生成视频内容 +// @Tags Video +// @Accept json +// @Produce json +// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)" +// @Param request body KlingText2VideoRequest true "视频生成请求参数" +// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果" +// @Failure 400 {object} dto.OpenAIError "请求参数错误" +// @Failure 401 {object} dto.OpenAIError "未授权" +// @Failure 403 {object} dto.OpenAIError "无权限" +// @Failure 500 {object} dto.OpenAIError "服务器内部错误" +// @Router /kling/v1/videos/text2video [post] +func KlingText2VideoGenerations(c *gin.Context) { +} + +type KlingText2VideoRequest struct { + ModelName string `json:"model_name,omitempty" example:"kling-v1"` + Prompt string `json:"prompt" binding:"required" example:"A cat playing piano in the garden"` + NegativePrompt string `json:"negative_prompt,omitempty" example:"blurry, low quality"` + CfgScale float64 `json:"cfg_scale,omitempty" example:"0.7"` + Mode string `json:"mode,omitempty" example:"std"` + CameraControl *KlingCameraControl `json:"camera_control,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"` + Duration string `json:"duration,omitempty" example:"5"` + CallbackURL string `json:"callback_url,omitempty" example:"https://your.domain/callback"` + ExternalTaskId string `json:"external_task_id,omitempty" example:"custom-task-001"` +} + +type KlingCameraControl struct { + Type string `json:"type,omitempty" example:"simple"` + Config *KlingCameraConfig `json:"config,omitempty"` +} + +type KlingCameraConfig struct { + Horizontal float64 `json:"horizontal,omitempty" example:"2.5"` + Vertical float64 `json:"vertical,omitempty" example:"0"` + Pan float64 `json:"pan,omitempty" example:"0"` + Tilt float64 `json:"tilt,omitempty" example:"0"` + Roll float64 `json:"roll,omitempty" example:"0"` + Zoom float64 `json:"zoom,omitempty" example:"0"` +} + +// KlingImage2VideoGenerations +// @Summary 可灵官方-图生视频 +// @Description 调用可灵AI图生视频接口,生成视频内容 +// @Tags Video +// @Accept json +// @Produce json +// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)" +// @Param request body KlingImage2VideoRequest true "图生视频请求参数" +// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果" +// @Failure 400 {object} dto.OpenAIError "请求参数错误" +// @Failure 401 {object} dto.OpenAIError "未授权" +// @Failure 403 {object} dto.OpenAIError "无权限" +// @Failure 500 {object} dto.OpenAIError "服务器内部错误" +// @Router /kling/v1/videos/image2video [post] +func KlingImage2VideoGenerations(c *gin.Context) { +} + +type KlingImage2VideoRequest struct { + ModelName string `json:"model_name,omitempty" example:"kling-v2-master"` + Image string `json:"image" binding:"required" example:"https://h2.inkwai.com/bs2/upload-ylab-stunt/se/ai_portal_queue_mmu_image_upscale_aiweb/3214b798-e1b4-4b00-b7af-72b5b0417420_raw_image_0.jpg"` + Prompt string `json:"prompt,omitempty" example:"A cat playing piano in the garden"` + NegativePrompt string `json:"negative_prompt,omitempty" example:"blurry, low quality"` + CfgScale float64 `json:"cfg_scale,omitempty" example:"0.7"` + Mode string `json:"mode,omitempty" example:"std"` + CameraControl *KlingCameraControl `json:"camera_control,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"` + Duration string `json:"duration,omitempty" example:"5"` + CallbackURL string `json:"callback_url,omitempty" example:"https://your.domain/callback"` + ExternalTaskId string `json:"external_task_id,omitempty" example:"custom-task-002"` +} + +// KlingImage2videoTaskId godoc +// @Summary 可灵任务查询--图生视频 +// @Description Query the status and result of a Kling video generation task by task ID +// @Tags Origin +// @Accept json +// @Produce json +// @Param task_id path string true "Task ID" +// @Router /kling/v1/videos/image2video/{task_id} [get] +func KlingImage2videoTaskId(c *gin.Context) {} + +// KlingText2videoTaskId godoc +// @Summary 可灵任务查询--文生视频 +// @Description Query the status and result of a Kling text-to-video generation task by task ID +// @Tags Origin +// @Accept json +// @Produce json +// @Param task_id path string true "Task ID" +// @Router /kling/v1/videos/text2video/{task_id} [get] +func KlingText2videoTaskId(c *gin.Context) {} diff --git a/controller/task.go b/controller/task.go new file mode 100644 index 0000000000000000000000000000000000000000..c14d7e21d9ff2e9a804f57d89daabea1eefb1816 --- /dev/null +++ b/controller/task.go @@ -0,0 +1,275 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay" + + "github.com/gin-gonic/gin" + "github.com/samber/lo" +) + +func UpdateTaskBulk() { + //revocer + //imageModel := "midjourney" + for { + time.Sleep(time.Duration(15) * time.Second) + common.SysLog("任务进度轮询开始") + ctx := context.TODO() + allTasks := model.GetAllUnFinishSyncTasks(500) + platformTask := make(map[constant.TaskPlatform][]*model.Task) + for _, t := range allTasks { + platformTask[t.Platform] = append(platformTask[t.Platform], t) + } + for platform, tasks := range platformTask { + if len(tasks) == 0 { + continue + } + taskChannelM := make(map[int][]string) + taskM := make(map[string]*model.Task) + nullTaskIds := make([]int64, 0) + for _, task := range tasks { + if task.TaskID == "" { + // 统计失败的未完成任务 + nullTaskIds = append(nullTaskIds, task.ID) + continue + } + taskM[task.TaskID] = task + taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], task.TaskID) + } + if len(nullTaskIds) > 0 { + err := model.TaskBulkUpdateByID(nullTaskIds, map[string]any{ + "status": "FAILURE", + "progress": "100%", + }) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err)) + } else { + logger.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds)) + } + } + if len(taskChannelM) == 0 { + continue + } + + UpdateTaskByPlatform(platform, taskChannelM, taskM) + } + common.SysLog("任务进度轮询完成") + } +} + +func UpdateTaskByPlatform(platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) { + switch platform { + case constant.TaskPlatformMidjourney: + //_ = UpdateMidjourneyTaskAll(context.Background(), tasks) + case constant.TaskPlatformSuno: + _ = UpdateSunoTaskAll(context.Background(), taskChannelM, taskM) + default: + if err := UpdateVideoTaskAll(context.Background(), platform, taskChannelM, taskM); err != nil { + common.SysLog(fmt.Sprintf("UpdateVideoTaskAll fail: %s", err)) + } + } +} + +func UpdateSunoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error { + for channelId, taskIds := range taskChannelM { + err := updateSunoTaskAll(ctx, channelId, taskIds, taskM) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %d", channelId, err.Error())) + } + } + return nil +} + +func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error { + logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds))) + if len(taskIds) == 0 { + return nil + } + channel, err := model.CacheGetChannel(channelId) + if err != nil { + common.SysLog(fmt.Sprintf("CacheGetChannel: %v", err)) + err = model.TaskBulkUpdate(taskIds, map[string]any{ + "fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId), + "status": "FAILURE", + "progress": "100%", + }) + if err != nil { + common.SysLog(fmt.Sprintf("UpdateMidjourneyTask error2: %v", err)) + } + return err + } + adaptor := relay.GetTaskAdaptor(constant.TaskPlatformSuno) + if adaptor == nil { + return errors.New("adaptor not found") + } + resp, err := adaptor.FetchTask(*channel.BaseURL, channel.Key, map[string]any{ + "ids": taskIds, + }) + if err != nil { + common.SysLog(fmt.Sprintf("Get Task Do req error: %v", err)) + return err + } + if resp.StatusCode != http.StatusOK { + logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode)) + return errors.New(fmt.Sprintf("Get Task status code: %d", resp.StatusCode)) + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + common.SysLog(fmt.Sprintf("Get Task parse body error: %v", err)) + return err + } + var responseItems dto.TaskResponse[[]dto.SunoDataResponse] + err = json.Unmarshal(responseBody, &responseItems) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody))) + return err + } + if !responseItems.IsSuccess() { + common.SysLog(fmt.Sprintf("渠道 #%d 未完成的任务有: %d, 成功获取到任务数: %d", channelId, len(taskIds), string(responseBody))) + return err + } + + for _, responseItem := range responseItems.Data { + task := taskM[responseItem.TaskID] + if !checkTaskNeedUpdate(task, responseItem) { + continue + } + + task.Status = lo.If(model.TaskStatus(responseItem.Status) != "", model.TaskStatus(responseItem.Status)).Else(task.Status) + task.FailReason = lo.If(responseItem.FailReason != "", responseItem.FailReason).Else(task.FailReason) + task.SubmitTime = lo.If(responseItem.SubmitTime != 0, responseItem.SubmitTime).Else(task.SubmitTime) + task.StartTime = lo.If(responseItem.StartTime != 0, responseItem.StartTime).Else(task.StartTime) + task.FinishTime = lo.If(responseItem.FinishTime != 0, responseItem.FinishTime).Else(task.FinishTime) + if responseItem.FailReason != "" || task.Status == model.TaskStatusFailure { + logger.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason) + task.Progress = "100%" + //err = model.CacheUpdateUserQuota(task.UserId) ? + if err != nil { + logger.LogError(ctx, "error update user quota cache: "+err.Error()) + } else { + quota := task.Quota + if quota != 0 { + err = model.IncreaseUserQuota(task.UserId, quota, false) + if err != nil { + logger.LogError(ctx, "fail to increase user quota: "+err.Error()) + } + logContent := fmt.Sprintf("异步任务执行失败 %s,补偿 %s", task.TaskID, logger.LogQuota(quota)) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + } + } + if responseItem.Status == model.TaskStatusSuccess { + task.Progress = "100%" + } + task.Data = responseItem.Data + + err = task.Update() + if err != nil { + common.SysLog("UpdateMidjourneyTask task error: " + err.Error()) + } + } + return nil +} + +func checkTaskNeedUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool { + + if oldTask.SubmitTime != newTask.SubmitTime { + return true + } + if oldTask.StartTime != newTask.StartTime { + return true + } + if oldTask.FinishTime != newTask.FinishTime { + return true + } + if string(oldTask.Status) != newTask.Status { + return true + } + if oldTask.FailReason != newTask.FailReason { + return true + } + if oldTask.FinishTime != newTask.FinishTime { + return true + } + + if (oldTask.Status == model.TaskStatusFailure || oldTask.Status == model.TaskStatusSuccess) && oldTask.Progress != "100%" { + return true + } + + oldData, _ := json.Marshal(oldTask.Data) + newData, _ := json.Marshal(newTask.Data) + + sort.Slice(oldData, func(i, j int) bool { + return oldData[i] < oldData[j] + }) + sort.Slice(newData, func(i, j int) bool { + return newData[i] < newData[j] + }) + + if string(oldData) != string(newData) { + return true + } + return false +} + +func GetAllTask(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + // 解析其他查询参数 + queryParams := model.SyncTaskQueryParams{ + Platform: constant.TaskPlatform(c.Query("platform")), + TaskID: c.Query("task_id"), + Status: c.Query("status"), + Action: c.Query("action"), + StartTimestamp: startTimestamp, + EndTimestamp: endTimestamp, + ChannelID: c.Query("channel_id"), + } + + items := model.TaskGetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) + total := model.TaskCountAllTasks(queryParams) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(items) + common.ApiSuccess(c, pageInfo) +} + +func GetUserTask(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + + userId := c.GetInt("id") + + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + + queryParams := model.SyncTaskQueryParams{ + Platform: constant.TaskPlatform(c.Query("platform")), + TaskID: c.Query("task_id"), + Status: c.Query("status"), + Action: c.Query("action"), + StartTimestamp: startTimestamp, + EndTimestamp: endTimestamp, + } + + items := model.TaskGetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) + total := model.TaskCountAllUserTask(userId, queryParams) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(items) + common.ApiSuccess(c, pageInfo) +} diff --git a/controller/task_video.go b/controller/task_video.go new file mode 100644 index 0000000000000000000000000000000000000000..8c9f9719e6b6cea47a45b81abaa7d4a0747ca94d --- /dev/null +++ b/controller/task_video.go @@ -0,0 +1,306 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + "io" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/ratio_setting" +) + +func UpdateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error { + for channelId, taskIds := range taskChannelM { + if err := updateVideoTaskAll(ctx, platform, channelId, taskIds, taskM); err != nil { + logger.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error())) + } + } + return nil +} + +func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error { + logger.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds))) + if len(taskIds) == 0 { + return nil + } + cacheGetChannel, err := model.CacheGetChannel(channelId) + if err != nil { + errUpdate := model.TaskBulkUpdate(taskIds, map[string]any{ + "fail_reason": fmt.Sprintf("Failed to get channel info, channel ID: %d", channelId), + "status": "FAILURE", + "progress": "100%", + }) + if errUpdate != nil { + common.SysLog(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate)) + } + return fmt.Errorf("CacheGetChannel failed: %w", err) + } + adaptor := relay.GetTaskAdaptor(platform) + if adaptor == nil { + return fmt.Errorf("video adaptor not found") + } + info := &relaycommon.RelayInfo{} + info.ChannelMeta = &relaycommon.ChannelMeta{ + ChannelBaseUrl: cacheGetChannel.GetBaseURL(), + } + info.ApiKey = cacheGetChannel.Key + adaptor.Init(info) + for _, taskId := range taskIds { + if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil { + logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error())) + } + } + return nil +} + +func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, channel *model.Channel, taskId string, taskM map[string]*model.Task) error { + baseURL := constant.ChannelBaseURLs[channel.Type] + if channel.GetBaseURL() != "" { + baseURL = channel.GetBaseURL() + } + + task := taskM[taskId] + if task == nil { + logger.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId)) + return fmt.Errorf("task %s not found", taskId) + } + resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{ + "task_id": taskId, + "action": task.Action, + }) + if err != nil { + return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err) + } + //if resp.StatusCode != http.StatusOK { + //return fmt.Errorf("get Video Task status code: %d", resp.StatusCode) + //} + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("readAll failed for task %s: %w", taskId, err) + } + + logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask response: %s", string(responseBody))) + + taskResult := &relaycommon.TaskInfo{} + // try parse as New API response format + var responseItems dto.TaskResponse[model.Task] + if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() { + logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask parsed as new api response format: %+v", responseItems)) + t := responseItems.Data + taskResult.TaskID = t.TaskID + taskResult.Status = string(t.Status) + taskResult.Url = t.FailReason + taskResult.Progress = t.Progress + taskResult.Reason = t.FailReason + task.Data = t.Data + } else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil { + return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err) + } else { + task.Data = redactVideoResponseBody(responseBody) + } + + logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask taskResult: %+v", taskResult)) + + now := time.Now().Unix() + if taskResult.Status == "" { + //return fmt.Errorf("task %s status is empty", taskId) + taskResult = relaycommon.FailTaskInfo("upstream returned empty status") + } + + // 记录原本的状态,防止重复退款 + shouldRefund := false + quota := task.Quota + preStatus := task.Status + + task.Status = model.TaskStatus(taskResult.Status) + switch taskResult.Status { + case model.TaskStatusSubmitted: + task.Progress = "10%" + case model.TaskStatusQueued: + task.Progress = "20%" + case model.TaskStatusInProgress: + task.Progress = "30%" + if task.StartTime == 0 { + task.StartTime = now + } + case model.TaskStatusSuccess: + task.Progress = "100%" + if task.FinishTime == 0 { + task.FinishTime = now + } + if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") { + task.FailReason = taskResult.Url + } + + // 如果返回了 total_tokens 并且配置了模型倍率(非固定价格),则重新计费 + if taskResult.TotalTokens > 0 { + // 获取模型名称 + var taskData map[string]interface{} + if err := json.Unmarshal(task.Data, &taskData); err == nil { + if modelName, ok := taskData["model"].(string); ok && modelName != "" { + // 获取模型价格和倍率 + modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName) + // 只有配置了倍率(非固定价格)时才按 token 重新计费 + if hasRatioSetting && modelRatio > 0 { + // 获取用户和组的倍率信息 + group := task.Group + if group == "" { + user, err := model.GetUserById(task.UserId, false) + if err == nil { + group = user.Group + } + } + if group != "" { + groupRatio := ratio_setting.GetGroupRatio(group) + userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(group, group) + + var finalGroupRatio float64 + if hasUserGroupRatio { + finalGroupRatio = userGroupRatio + } else { + finalGroupRatio = groupRatio + } + + // 计算实际应扣费额度: totalTokens * modelRatio * groupRatio + actualQuota := int(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio) + + // 计算差额 + preConsumedQuota := task.Quota + quotaDelta := actualQuota - preConsumedQuota + + if quotaDelta > 0 { + // 需要补扣费 + logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后补扣费:%s(实际消耗:%s,预扣费:%s,tokens:%d)", + task.TaskID, + logger.LogQuota(quotaDelta), + logger.LogQuota(actualQuota), + logger.LogQuota(preConsumedQuota), + taskResult.TotalTokens, + )) + if err := model.DecreaseUserQuota(task.UserId, quotaDelta); err != nil { + logger.LogError(ctx, fmt.Sprintf("补扣费失败: %s", err.Error())) + } else { + model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta) + model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta) + task.Quota = actualQuota // 更新任务记录的实际扣费额度 + + // 记录消费日志 + logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s", + modelRatio, finalGroupRatio, taskResult.TotalTokens, + logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta)) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + } else if quotaDelta < 0 { + // 需要退还多扣的费用 + refundQuota := -quotaDelta + logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后返还:%s(实际消耗:%s,预扣费:%s,tokens:%d)", + task.TaskID, + logger.LogQuota(refundQuota), + logger.LogQuota(actualQuota), + logger.LogQuota(preConsumedQuota), + taskResult.TotalTokens, + )) + if err := model.IncreaseUserQuota(task.UserId, refundQuota, false); err != nil { + logger.LogError(ctx, fmt.Sprintf("退还预扣费失败: %s", err.Error())) + } else { + task.Quota = actualQuota // 更新任务记录的实际扣费额度 + + // 记录退款日志 + logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s", + modelRatio, finalGroupRatio, taskResult.TotalTokens, + logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota)) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + } else { + // quotaDelta == 0, 预扣费刚好准确 + logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费准确(%s,tokens:%d)", + task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens)) + } + } + } + } + } + } + case model.TaskStatusFailure: + logger.LogJson(ctx, fmt.Sprintf("Task %s failed", taskId), task) + task.Status = model.TaskStatusFailure + task.Progress = "100%" + if task.FinishTime == 0 { + task.FinishTime = now + } + task.FailReason = taskResult.Reason + logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason)) + taskResult.Progress = "100%" + if quota != 0 { + if preStatus != model.TaskStatusFailure { + shouldRefund = true + } else { + logger.LogWarn(ctx, fmt.Sprintf("Task %s already in failure status, skip refund", task.TaskID)) + } + } + default: + return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, taskId) + } + if taskResult.Progress != "" { + task.Progress = taskResult.Progress + } + if err := task.Update(); err != nil { + common.SysLog("UpdateVideoTask task error: " + err.Error()) + shouldRefund = false + } + + if shouldRefund { + // 任务失败且之前状态不是失败才退还额度,防止重复退还 + if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil { + logger.LogWarn(ctx, "Failed to increase user quota: "+err.Error()) + } + logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, logger.LogQuota(quota)) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + + return nil +} + +func redactVideoResponseBody(body []byte) []byte { + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + return body + } + resp, _ := m["response"].(map[string]any) + if resp != nil { + delete(resp, "bytesBase64Encoded") + if v, ok := resp["video"].(string); ok { + resp["video"] = truncateBase64(v) + } + if vs, ok := resp["videos"].([]any); ok { + for i := range vs { + if vm, ok := vs[i].(map[string]any); ok { + delete(vm, "bytesBase64Encoded") + } + } + } + } + b, err := json.Marshal(m) + if err != nil { + return body + } + return b +} + +func truncateBase64(s string) string { + const maxKeep = 256 + if len(s) <= maxKeep { + return s + } + return s[:maxKeep] + "..." +} diff --git a/controller/telegram.go b/controller/telegram.go new file mode 100644 index 0000000000000000000000000000000000000000..f16cdd66c545df810f97c5cb9d925dcb41d7100c --- /dev/null +++ b/controller/telegram.go @@ -0,0 +1,125 @@ +package controller + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "sort" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +func TelegramBind(c *gin.Context) { + if !common.TelegramOAuthEnabled { + c.JSON(200, gin.H{ + "message": "管理员未开启通过 Telegram 登录以及注册", + "success": false, + }) + return + } + params := c.Request.URL.Query() + if !checkTelegramAuthorization(params, common.TelegramBotToken) { + c.JSON(200, gin.H{ + "message": "无效的请求", + "success": false, + }) + return + } + telegramId := params["id"][0] + if model.IsTelegramIdAlreadyTaken(telegramId) { + c.JSON(200, gin.H{ + "message": "该 Telegram 账户已被绑定", + "success": false, + }) + return + } + + session := sessions.Default(c) + id := session.Get("id") + user := model.User{Id: id.(int)} + if err := user.FillUserById(); err != nil { + c.JSON(200, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + if user.Id == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已注销", + }) + return + } + user.TelegramId = telegramId + if err := user.Update(false); err != nil { + c.JSON(200, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + + c.Redirect(302, "/console/personal") +} + +func TelegramLogin(c *gin.Context) { + if !common.TelegramOAuthEnabled { + c.JSON(200, gin.H{ + "message": "管理员未开启通过 Telegram 登录以及注册", + "success": false, + }) + return + } + params := c.Request.URL.Query() + if !checkTelegramAuthorization(params, common.TelegramBotToken) { + c.JSON(200, gin.H{ + "message": "无效的请求", + "success": false, + }) + return + } + + telegramId := params["id"][0] + user := model.User{TelegramId: telegramId} + if err := user.FillUserByTelegramId(); err != nil { + c.JSON(200, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + setupLogin(&user, c) +} + +func checkTelegramAuthorization(params map[string][]string, token string) bool { + strs := []string{} + var hash = "" + for k, v := range params { + if k == "hash" { + hash = v[0] + continue + } + strs = append(strs, k+"="+v[0]) + } + sort.Strings(strs) + var imploded = "" + for _, s := range strs { + if imploded != "" { + imploded += "\n" + } + imploded += s + } + sha256hash := sha256.New() + io.WriteString(sha256hash, token) + hmachash := hmac.New(sha256.New, sha256hash.Sum(nil)) + io.WriteString(hmachash, imploded) + ss := hex.EncodeToString(hmachash.Sum(nil)) + return hash == ss +} diff --git a/controller/token.go b/controller/token.go new file mode 100644 index 0000000000000000000000000000000000000000..04e31f8c1476bb89fc4255e957c96399dbd57e77 --- /dev/null +++ b/controller/token.go @@ -0,0 +1,289 @@ +package controller + +import ( + "net/http" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +func GetAllTokens(c *gin.Context) { + userId := c.GetInt("id") + pageInfo := common.GetPageQuery(c) + tokens, err := model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + total, _ := model.CountUserTokens(userId) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(tokens) + common.ApiSuccess(c, pageInfo) + return +} + +func SearchTokens(c *gin.Context) { + userId := c.GetInt("id") + keyword := c.Query("keyword") + token := c.Query("token") + tokens, err := model.SearchUserTokens(userId, keyword, token) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": tokens, + }) + return +} + +func GetToken(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + userId := c.GetInt("id") + if err != nil { + common.ApiError(c, err) + return + } + token, err := model.GetTokenByIds(id, userId) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": token, + }) + return +} + +func GetTokenStatus(c *gin.Context) { + tokenId := c.GetInt("token_id") + userId := c.GetInt("id") + token, err := model.GetTokenByIds(tokenId, userId) + if err != nil { + common.ApiError(c, err) + return + } + expiredAt := token.ExpiredTime + if expiredAt == -1 { + expiredAt = 0 + } + c.JSON(http.StatusOK, gin.H{ + "object": "credit_summary", + "total_granted": token.RemainQuota, + "total_used": 0, // not supported currently + "total_available": token.RemainQuota, + "expires_at": expiredAt * 1000, + }) +} + +func GetTokenUsage(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "No Authorization header", + }) + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Invalid Bearer token", + }) + return + } + tokenKey := parts[1] + + token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + expiredAt := token.ExpiredTime + if expiredAt == -1 { + expiredAt = 0 + } + + c.JSON(http.StatusOK, gin.H{ + "code": true, + "message": "ok", + "data": gin.H{ + "object": "token_usage", + "name": token.Name, + "total_granted": token.RemainQuota + token.UsedQuota, + "total_used": token.UsedQuota, + "total_available": token.RemainQuota, + "unlimited_quota": token.UnlimitedQuota, + "model_limits": token.GetModelLimitsMap(), + "model_limits_enabled": token.ModelLimitsEnabled, + "expires_at": expiredAt, + }, + }) +} + +func AddToken(c *gin.Context) { + token := model.Token{} + err := c.ShouldBindJSON(&token) + if err != nil { + common.ApiError(c, err) + return + } + if len(token.Name) > 30 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "令牌名称过长", + }) + return + } + key, err := common.GenerateKey() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成令牌失败", + }) + common.SysLog("failed to generate token key: " + err.Error()) + return + } + cleanToken := model.Token{ + UserId: c.GetInt("id"), + Name: token.Name, + Key: key, + CreatedTime: common.GetTimestamp(), + AccessedTime: common.GetTimestamp(), + ExpiredTime: token.ExpiredTime, + RemainQuota: token.RemainQuota, + UnlimitedQuota: token.UnlimitedQuota, + ModelLimitsEnabled: token.ModelLimitsEnabled, + ModelLimits: token.ModelLimits, + AllowIps: token.AllowIps, + Group: token.Group, + } + err = cleanToken.Insert() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func DeleteToken(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + userId := c.GetInt("id") + err := model.DeleteTokenById(id, userId) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func UpdateToken(c *gin.Context) { + userId := c.GetInt("id") + statusOnly := c.Query("status_only") + token := model.Token{} + err := c.ShouldBindJSON(&token) + if err != nil { + common.ApiError(c, err) + return + } + if len(token.Name) > 30 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "令牌名称过长", + }) + return + } + cleanToken, err := model.GetTokenByIds(token.Id, userId) + if err != nil { + common.ApiError(c, err) + return + } + if token.Status == common.TokenStatusEnabled { + if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期", + }) + return + } + if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度", + }) + return + } + } + if statusOnly != "" { + cleanToken.Status = token.Status + } else { + // If you add more fields, please also update token.Update() + cleanToken.Name = token.Name + cleanToken.ExpiredTime = token.ExpiredTime + cleanToken.RemainQuota = token.RemainQuota + cleanToken.UnlimitedQuota = token.UnlimitedQuota + cleanToken.ModelLimitsEnabled = token.ModelLimitsEnabled + cleanToken.ModelLimits = token.ModelLimits + cleanToken.AllowIps = token.AllowIps + cleanToken.Group = token.Group + } + err = cleanToken.Update() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": cleanToken, + }) + return +} + +type TokenBatch struct { + Ids []int `json:"ids"` +} + +func DeleteTokenBatch(c *gin.Context) { + tokenBatch := TokenBatch{} + if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + userId := c.GetInt("id") + count, err := model.BatchDeleteTokens(tokenBatch.Ids, userId) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": count, + }) +} diff --git a/controller/topup.go b/controller/topup.go new file mode 100644 index 0000000000000000000000000000000000000000..f34d4b892d6904cbeb64760ce27b138c5ad3f342 --- /dev/null +++ b/controller/topup.go @@ -0,0 +1,392 @@ +package controller + +import ( + "fmt" + "log" + "net/url" + "strconv" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/Calcium-Ion/go-epay/epay" + "github.com/gin-gonic/gin" + "github.com/samber/lo" + "github.com/shopspring/decimal" +) + +func GetTopUpInfo(c *gin.Context) { + // 获取支付方式 + payMethods := operation_setting.PayMethods + + // 如果启用了 Stripe 支付,添加到支付方法列表 + if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" { + // 检查是否已经包含 Stripe + hasStripe := false + for _, method := range payMethods { + if method["type"] == "stripe" { + hasStripe = true + break + } + } + + if !hasStripe { + stripeMethod := map[string]string{ + "name": "Stripe", + "type": "stripe", + "color": "rgba(var(--semi-purple-5), 1)", + "min_topup": strconv.Itoa(setting.StripeMinTopUp), + } + payMethods = append(payMethods, stripeMethod) + } + } + + data := gin.H{ + "enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "", + "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", + "enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]", + "creem_products": setting.CreemProducts, + "pay_methods": payMethods, + "min_topup": operation_setting.MinTopUp, + "stripe_min_topup": setting.StripeMinTopUp, + "amount_options": operation_setting.GetPaymentSetting().AmountOptions, + "discount": operation_setting.GetPaymentSetting().AmountDiscount, + } + common.ApiSuccess(c, data) +} + +type EpayRequest struct { + Amount int64 `json:"amount"` + PaymentMethod string `json:"payment_method"` + TopUpCode string `json:"top_up_code"` +} + +type AmountRequest struct { + Amount int64 `json:"amount"` + TopUpCode string `json:"top_up_code"` +} + +func GetEpayClient() *epay.Client { + if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" { + return nil + } + withUrl, err := epay.NewClient(&epay.Config{ + PartnerID: operation_setting.EpayId, + Key: operation_setting.EpayKey, + }, operation_setting.PayAddress) + if err != nil { + return nil + } + return withUrl +} + +func getPayMoney(amount int64, group string) float64 { + dAmount := decimal.NewFromInt(amount) + // 充值金额以“展示类型”为准: + // - USD/CNY: 前端传 amount 为金额单位;TOKENS: 前端传 tokens,需要换成 USD 金额 + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + dAmount = dAmount.Div(dQuotaPerUnit) + } + + topupGroupRatio := common.GetTopupGroupRatio(group) + if topupGroupRatio == 0 { + topupGroupRatio = 1 + } + + dTopupGroupRatio := decimal.NewFromFloat(topupGroupRatio) + dPrice := decimal.NewFromFloat(operation_setting.Price) + // apply optional preset discount by the original request amount (if configured), default 1.0 + discount := 1.0 + if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok { + if ds > 0 { + discount = ds + } + } + dDiscount := decimal.NewFromFloat(discount) + + payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio).Mul(dDiscount) + + return payMoney.InexactFloat64() +} + +func getMinTopup() int64 { + minTopup := operation_setting.MinTopUp + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + dMinTopup := decimal.NewFromInt(int64(minTopup)) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart()) + } + return int64(minTopup) +} + +func RequestEpay(c *gin.Context) { + var req EpayRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + if req.Amount < getMinTopup() { + c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())}) + return + } + + id := c.GetInt("id") + group, err := model.GetUserGroup(id, true) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + payMoney := getPayMoney(req.Amount, group) + if payMoney < 0.01 { + c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + + if !operation_setting.ContainsPayMethod(req.PaymentMethod) { + c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"}) + return + } + + callBackAddress := service.GetCallbackAddress() + returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log") + notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify") + tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix()) + tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo) + client := GetEpayClient() + if client == nil { + c.JSON(200, gin.H{"message": "error", "data": "当前管理员未配置支付信息"}) + return + } + uri, params, err := client.Purchase(&epay.PurchaseArgs{ + Type: req.PaymentMethod, + ServiceTradeNo: tradeNo, + Name: fmt.Sprintf("TUC%d", req.Amount), + Money: strconv.FormatFloat(payMoney, 'f', 2, 64), + Device: epay.PC, + NotifyUrl: notifyUrl, + ReturnUrl: returnUrl, + }) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + amount := req.Amount + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + dAmount := decimal.NewFromInt(int64(amount)) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + amount = dAmount.Div(dQuotaPerUnit).IntPart() + } + topUp := &model.TopUp{ + UserId: id, + Amount: amount, + Money: payMoney, + TradeNo: tradeNo, + PaymentMethod: req.PaymentMethod, + CreateTime: time.Now().Unix(), + Status: "pending", + } + err = topUp.Insert() + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) + return + } + c.JSON(200, gin.H{"message": "success", "data": params, "url": uri}) +} + +// tradeNo lock +var orderLocks sync.Map +var createLock sync.Mutex + +// LockOrder 尝试对给定订单号加锁 +func LockOrder(tradeNo string) { + lock, ok := orderLocks.Load(tradeNo) + if !ok { + createLock.Lock() + defer createLock.Unlock() + lock, ok = orderLocks.Load(tradeNo) + if !ok { + lock = new(sync.Mutex) + orderLocks.Store(tradeNo, lock) + } + } + lock.(*sync.Mutex).Lock() +} + +// UnlockOrder 释放给定订单号的锁 +func UnlockOrder(tradeNo string) { + lock, ok := orderLocks.Load(tradeNo) + if ok { + lock.(*sync.Mutex).Unlock() + } +} + +func EpayNotify(c *gin.Context) { + params := lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string { + r[t] = c.Request.URL.Query().Get(t) + return r + }, map[string]string{}) + client := GetEpayClient() + if client == nil { + log.Println("易支付回调失败 未找到配置信息") + _, err := c.Writer.Write([]byte("fail")) + if err != nil { + log.Println("易支付回调写入失败") + } + return + } + verifyInfo, err := client.Verify(params) + if err == nil && verifyInfo.VerifyStatus { + _, err := c.Writer.Write([]byte("success")) + if err != nil { + log.Println("易支付回调写入失败") + } + } else { + _, err := c.Writer.Write([]byte("fail")) + if err != nil { + log.Println("易支付回调写入失败") + } + log.Println("易支付回调签名验证失败") + return + } + + if verifyInfo.TradeStatus == epay.StatusTradeSuccess { + log.Println(verifyInfo) + LockOrder(verifyInfo.ServiceTradeNo) + defer UnlockOrder(verifyInfo.ServiceTradeNo) + topUp := model.GetTopUpByTradeNo(verifyInfo.ServiceTradeNo) + if topUp == nil { + log.Printf("易支付回调未找到订单: %v", verifyInfo) + return + } + if topUp.Status == "pending" { + topUp.Status = "success" + err := topUp.Update() + if err != nil { + log.Printf("易支付回调更新订单失败: %v", topUp) + return + } + //user, _ := model.GetUserById(topUp.UserId, false) + //user.Quota += topUp.Amount * 500000 + dAmount := decimal.NewFromInt(int64(topUp.Amount)) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart()) + err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true) + if err != nil { + log.Printf("易支付回调更新用户失败: %v", topUp) + return + } + log.Printf("易支付回调更新用户成功 %v", topUp) + model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money)) + } + } else { + log.Printf("易支付异常回调: %v", verifyInfo) + } +} + +func RequestAmount(c *gin.Context) { + var req AmountRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + + if req.Amount < getMinTopup() { + c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())}) + return + } + id := c.GetInt("id") + group, err := model.GetUserGroup(id, true) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + payMoney := getPayMoney(req.Amount, group) + if payMoney <= 0.01 { + c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)}) +} + +func GetUserTopUps(c *gin.Context) { + userId := c.GetInt("id") + pageInfo := common.GetPageQuery(c) + keyword := c.Query("keyword") + + var ( + topups []*model.TopUp + total int64 + err error + ) + if keyword != "" { + topups, total, err = model.SearchUserTopUps(userId, keyword, pageInfo) + } else { + topups, total, err = model.GetUserTopUps(userId, pageInfo) + } + if err != nil { + common.ApiError(c, err) + return + } + + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(topups) + common.ApiSuccess(c, pageInfo) +} + +// GetAllTopUps 管理员获取全平台充值记录 +func GetAllTopUps(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + keyword := c.Query("keyword") + + var ( + topups []*model.TopUp + total int64 + err error + ) + if keyword != "" { + topups, total, err = model.SearchAllTopUps(keyword, pageInfo) + } else { + topups, total, err = model.GetAllTopUps(pageInfo) + } + if err != nil { + common.ApiError(c, err) + return + } + + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(topups) + common.ApiSuccess(c, pageInfo) +} + +type AdminCompleteTopupRequest struct { + TradeNo string `json:"trade_no"` +} + +// AdminCompleteTopUp 管理员补单接口 +func AdminCompleteTopUp(c *gin.Context) { + var req AdminCompleteTopupRequest + if err := c.ShouldBindJSON(&req); err != nil || req.TradeNo == "" { + common.ApiErrorMsg(c, "参数错误") + return + } + + // 订单级互斥,防止并发补单 + LockOrder(req.TradeNo) + defer UnlockOrder(req.TradeNo) + + if err := model.ManualCompleteTopUp(req.TradeNo); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} diff --git a/controller/topup_creem.go b/controller/topup_creem.go new file mode 100644 index 0000000000000000000000000000000000000000..aab951c54a311ce743deaef6ec93144204bd4da2 --- /dev/null +++ b/controller/topup_creem.go @@ -0,0 +1,461 @@ +package controller + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting" + "time" + + "github.com/gin-gonic/gin" + "github.com/thanhpk/randstr" +) + +const ( + PaymentMethodCreem = "creem" + CreemSignatureHeader = "creem-signature" +) + +var creemAdaptor = &CreemAdaptor{} + +// 生成HMAC-SHA256签名 +func generateCreemSignature(payload string, secret string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(payload)) + return hex.EncodeToString(h.Sum(nil)) +} + +// 验证Creem webhook签名 +func verifyCreemSignature(payload string, signature string, secret string) bool { + if secret == "" { + log.Printf("Creem webhook secret not set") + if setting.CreemTestMode { + log.Printf("Skip Creem webhook sign verify in test mode") + return true + } + return false + } + + expectedSignature := generateCreemSignature(payload, secret) + return hmac.Equal([]byte(signature), []byte(expectedSignature)) +} + +type CreemPayRequest struct { + ProductId string `json:"product_id"` + PaymentMethod string `json:"payment_method"` +} + +type CreemProduct struct { + ProductId string `json:"productId"` + Name string `json:"name"` + Price float64 `json:"price"` + Currency string `json:"currency"` + Quota int64 `json:"quota"` +} + +type CreemAdaptor struct { +} + +func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) { + if req.PaymentMethod != PaymentMethodCreem { + c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"}) + return + } + + if req.ProductId == "" { + c.JSON(200, gin.H{"message": "error", "data": "请选择产品"}) + return + } + + // 解析产品列表 + var products []CreemProduct + err := json.Unmarshal([]byte(setting.CreemProducts), &products) + if err != nil { + log.Println("解析Creem产品列表失败", err) + c.JSON(200, gin.H{"message": "error", "data": "产品配置错误"}) + return + } + + // 查找对应的产品 + var selectedProduct *CreemProduct + for _, product := range products { + if product.ProductId == req.ProductId { + selectedProduct = &product + break + } + } + + if selectedProduct == nil { + c.JSON(200, gin.H{"message": "error", "data": "产品不存在"}) + return + } + + id := c.GetInt("id") + user, _ := model.GetUserById(id, false) + + // 生成唯一的订单引用ID + reference := fmt.Sprintf("creem-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) + referenceId := "ref_" + common.Sha1([]byte(reference)) + + // 先创建订单记录,使用产品配置的金额和充值额度 + topUp := &model.TopUp{ + UserId: id, + Amount: selectedProduct.Quota, // 充值额度 + Money: selectedProduct.Price, // 支付金额 + TradeNo: referenceId, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + err = topUp.Insert() + if err != nil { + log.Printf("创建Creem订单失败: %v", err) + c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) + return + } + + // 创建支付链接,传入用户邮箱 + checkoutUrl, err := genCreemLink(referenceId, selectedProduct, user.Email, user.Username) + if err != nil { + log.Printf("获取Creem支付链接失败: %v", err) + c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + + log.Printf("Creem订单创建成功 - 用户ID: %d, 订单号: %s, 产品: %s, 充值额度: %d, 支付金额: %.2f", + id, referenceId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price) + + c.JSON(200, gin.H{ + "message": "success", + "data": gin.H{ + "checkout_url": checkoutUrl, + "order_id": referenceId, + }, + }) +} + +func RequestCreemPay(c *gin.Context) { + var req CreemPayRequest + + // 读取body内容用于打印,同时保留原始数据供后续使用 + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Printf("read creem pay req body err: %v", err) + c.JSON(200, gin.H{"message": "error", "data": "read query error"}) + return + } + + // 打印body内容 + log.Printf("creem pay request body: %s", string(bodyBytes)) + + // 重新设置body供后续的ShouldBindJSON使用 + c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + + err = c.ShouldBindJSON(&req) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + creemAdaptor.RequestPay(c, &req) +} + +// 新的Creem Webhook结构体,匹配实际的webhook数据格式 +type CreemWebhookEvent struct { + Id string `json:"id"` + EventType string `json:"eventType"` + CreatedAt int64 `json:"created_at"` + Object struct { + Id string `json:"id"` + Object string `json:"object"` + RequestId string `json:"request_id"` + Order struct { + Object string `json:"object"` + Id string `json:"id"` + Customer string `json:"customer"` + Product string `json:"product"` + Amount int `json:"amount"` + Currency string `json:"currency"` + SubTotal int `json:"sub_total"` + TaxAmount int `json:"tax_amount"` + AmountDue int `json:"amount_due"` + AmountPaid int `json:"amount_paid"` + Status string `json:"status"` + Type string `json:"type"` + Transaction string `json:"transaction"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Mode string `json:"mode"` + } `json:"order"` + Product struct { + Id string `json:"id"` + Object string `json:"object"` + Name string `json:"name"` + Description string `json:"description"` + Price int `json:"price"` + Currency string `json:"currency"` + BillingType string `json:"billing_type"` + BillingPeriod string `json:"billing_period"` + Status string `json:"status"` + TaxMode string `json:"tax_mode"` + TaxCategory string `json:"tax_category"` + DefaultSuccessUrl *string `json:"default_success_url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Mode string `json:"mode"` + } `json:"product"` + Units int `json:"units"` + Customer struct { + Id string `json:"id"` + Object string `json:"object"` + Email string `json:"email"` + Name string `json:"name"` + Country string `json:"country"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Mode string `json:"mode"` + } `json:"customer"` + Status string `json:"status"` + Metadata map[string]string `json:"metadata"` + Mode string `json:"mode"` + } `json:"object"` +} + +// 保留旧的结构体作为兼容 +type CreemWebhookData struct { + Type string `json:"type"` + Data struct { + RequestId string `json:"request_id"` + Status string `json:"status"` + Metadata map[string]string `json:"metadata"` + } `json:"data"` +} + +func CreemWebhook(c *gin.Context) { + // 读取body内容用于打印,同时保留原始数据供后续使用 + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Printf("读取Creem Webhook请求body失败: %v", err) + c.AbortWithStatus(http.StatusBadRequest) + return + } + + // 获取签名头 + signature := c.GetHeader(CreemSignatureHeader) + + // 打印关键信息(避免输出完整敏感payload) + log.Printf("Creem Webhook - URI: %s", c.Request.RequestURI) + if setting.CreemTestMode { + log.Printf("Creem Webhook - Signature: %s , Body: %s", signature, bodyBytes) + } else if signature == "" { + log.Printf("Creem Webhook缺少签名头") + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + // 验证签名 + if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) { + log.Printf("Creem Webhook签名验证失败") + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + log.Printf("Creem Webhook签名验证成功") + + // 重新设置body供后续的ShouldBindJSON使用 + c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + + // 解析新格式的webhook数据 + var webhookEvent CreemWebhookEvent + if err := c.ShouldBindJSON(&webhookEvent); err != nil { + log.Printf("解析Creem Webhook参数失败: %v", err) + c.AbortWithStatus(http.StatusBadRequest) + return + } + + log.Printf("Creem Webhook解析成功 - EventType: %s, EventId: %s", webhookEvent.EventType, webhookEvent.Id) + + // 根据事件类型处理不同的webhook + switch webhookEvent.EventType { + case "checkout.completed": + handleCheckoutCompleted(c, &webhookEvent) + default: + log.Printf("忽略Creem Webhook事件类型: %s", webhookEvent.EventType) + c.Status(http.StatusOK) + } +} + +// 处理支付完成事件 +func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) { + // 验证订单状态 + if event.Object.Order.Status != "paid" { + log.Printf("订单状态不是已支付: %s, 跳过处理", event.Object.Order.Status) + c.Status(http.StatusOK) + return + } + + // 获取引用ID(这是我们创建订单时传递的request_id) + referenceId := event.Object.RequestId + if referenceId == "" { + log.Println("Creem Webhook缺少request_id字段") + c.AbortWithStatus(http.StatusBadRequest) + return + } + + // 验证订单类型,目前只处理一次性付款 + if event.Object.Order.Type != "onetime" { + log.Printf("暂不支持的订单类型: %s, 跳过处理", event.Object.Order.Type) + c.Status(http.StatusOK) + return + } + + // 记录详细的支付信息 + log.Printf("处理Creem支付完成 - 订单号: %s, Creem订单ID: %s, 支付金额: %d %s, 客户邮箱: , 产品: %s", + referenceId, + event.Object.Order.Id, + event.Object.Order.AmountPaid, + event.Object.Order.Currency, + event.Object.Product.Name) + + // 查询本地订单确认存在 + topUp := model.GetTopUpByTradeNo(referenceId) + if topUp == nil { + log.Printf("Creem充值订单不存在: %s", referenceId) + c.AbortWithStatus(http.StatusBadRequest) + return + } + + if topUp.Status != common.TopUpStatusPending { + log.Printf("Creem充值订单状态错误: %s, 当前状态: %s", referenceId, topUp.Status) + c.Status(http.StatusOK) // 已处理过的订单,返回成功避免重复处理 + return + } + + // 处理充值,传入客户邮箱和姓名信息 + customerEmail := event.Object.Customer.Email + customerName := event.Object.Customer.Name + + // 防护性检查,确保邮箱和姓名不为空字符串 + if customerEmail == "" { + log.Printf("警告:Creem回调中客户邮箱为空 - 订单号: %s", referenceId) + } + if customerName == "" { + log.Printf("警告:Creem回调中客户姓名为空 - 订单号: %s", referenceId) + } + + err := model.RechargeCreem(referenceId, customerEmail, customerName) + if err != nil { + log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + log.Printf("Creem充值成功 - 订单号: %s, 充值额度: %d, 支付金额: %.2f", + referenceId, topUp.Amount, topUp.Money) + c.Status(http.StatusOK) +} + +type CreemCheckoutRequest struct { + ProductId string `json:"product_id"` + RequestId string `json:"request_id"` + Customer struct { + Email string `json:"email"` + } `json:"customer"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type CreemCheckoutResponse struct { + CheckoutUrl string `json:"checkout_url"` + Id string `json:"id"` +} + +func genCreemLink(referenceId string, product *CreemProduct, email string, username string) (string, error) { + if setting.CreemApiKey == "" { + return "", fmt.Errorf("未配置Creem API密钥") + } + + // 根据测试模式选择 API 端点 + apiUrl := "https://api.creem.io/v1/checkouts" + if setting.CreemTestMode { + apiUrl = "https://test-api.creem.io/v1/checkouts" + log.Printf("使用Creem测试环境: %s", apiUrl) + } + + // 构建请求数据,确保包含用户邮箱 + requestData := CreemCheckoutRequest{ + ProductId: product.ProductId, + RequestId: referenceId, // 这个作为订单ID传递给Creem + Customer: struct { + Email string `json:"email"` + }{ + Email: email, // 用户邮箱会在支付页面预填充 + }, + Metadata: map[string]string{ + "username": username, + "reference_id": referenceId, + "product_name": product.Name, + "quota": fmt.Sprintf("%d", product.Quota), + }, + } + + // 序列化请求数据 + jsonData, err := json.Marshal(requestData) + if err != nil { + return "", fmt.Errorf("序列化请求数据失败: %v", err) + } + + // 创建 HTTP 请求 + req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("创建HTTP请求失败: %v", err) + } + + // 设置请求头 + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", setting.CreemApiKey) + + log.Printf("发送Creem支付请求 - URL: %s, 产品ID: %s, 用户邮箱: %s, 订单号: %s", + apiUrl, product.ProductId, email, referenceId) + + // 发送请求 + client := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("发送HTTP请求失败: %v", err) + } + defer resp.Body.Close() + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取响应失败: %v", err) + } + + log.Printf("Creem API resp - status code: %d, resp: %s", resp.StatusCode, string(body)) + + // 检查响应状态 + if resp.StatusCode/100 != 2 { + return "", fmt.Errorf("Creem API http status %d ", resp.StatusCode) + } + // 解析响应 + var checkoutResp CreemCheckoutResponse + err = json.Unmarshal(body, &checkoutResp) + if err != nil { + return "", fmt.Errorf("解析响应失败: %v", err) + } + + if checkoutResp.CheckoutUrl == "" { + return "", fmt.Errorf("Creem API resp no checkout url ") + } + + log.Printf("Creem 支付链接创建成功 - 订单号: %s, 支付链接: %s", referenceId, checkoutResp.CheckoutUrl) + return checkoutResp.CheckoutUrl, nil +} diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go new file mode 100644 index 0000000000000000000000000000000000000000..337ff8e738a1ef415004fd76c0ad40f67df53191 --- /dev/null +++ b/controller/topup_stripe.go @@ -0,0 +1,288 @@ +package controller + +import ( + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-gonic/gin" + "github.com/stripe/stripe-go/v81" + "github.com/stripe/stripe-go/v81/checkout/session" + "github.com/stripe/stripe-go/v81/webhook" + "github.com/thanhpk/randstr" +) + +const ( + PaymentMethodStripe = "stripe" +) + +var stripeAdaptor = &StripeAdaptor{} + +type StripePayRequest struct { + Amount int64 `json:"amount"` + PaymentMethod string `json:"payment_method"` +} + +type StripeAdaptor struct { +} + +func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) { + if req.Amount < getStripeMinTopup() { + c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())}) + return + } + id := c.GetInt("id") + group, err := model.GetUserGroup(id, true) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + payMoney := getStripePayMoney(float64(req.Amount), group) + if payMoney <= 0.01 { + c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)}) +} + +func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { + if req.PaymentMethod != PaymentMethodStripe { + c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"}) + return + } + if req.Amount < getStripeMinTopup() { + c.JSON(200, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10}) + return + } + if req.Amount > 10000 { + c.JSON(200, gin.H{"message": "充值数量不能大于 10000", "data": 10}) + return + } + + id := c.GetInt("id") + user, _ := model.GetUserById(id, false) + chargedMoney := GetChargedAmount(float64(req.Amount), *user) + + reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) + referenceId := "ref_" + common.Sha1([]byte(reference)) + + payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount) + if err != nil { + log.Println("获取Stripe Checkout支付链接失败", err) + c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + + topUp := &model.TopUp{ + UserId: id, + Amount: req.Amount, + Money: chargedMoney, + TradeNo: referenceId, + PaymentMethod: PaymentMethodStripe, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + err = topUp.Insert() + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) + return + } + c.JSON(200, gin.H{ + "message": "success", + "data": gin.H{ + "pay_link": payLink, + }, + }) +} + +func RequestStripeAmount(c *gin.Context) { + var req StripePayRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + stripeAdaptor.RequestAmount(c, &req) +} + +func RequestStripePay(c *gin.Context) { + var req StripePayRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + stripeAdaptor.RequestPay(c, &req) +} + +func StripeWebhook(c *gin.Context) { + payload, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Printf("解析Stripe Webhook参数失败: %v\n", err) + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + + signature := c.GetHeader("Stripe-Signature") + endpointSecret := setting.StripeWebhookSecret + event, err := webhook.ConstructEventWithOptions(payload, signature, endpointSecret, webhook.ConstructEventOptions{ + IgnoreAPIVersionMismatch: true, + }) + + if err != nil { + log.Printf("Stripe Webhook验签失败: %v\n", err) + c.AbortWithStatus(http.StatusBadRequest) + return + } + + switch event.Type { + case stripe.EventTypeCheckoutSessionCompleted: + sessionCompleted(event) + case stripe.EventTypeCheckoutSessionExpired: + sessionExpired(event) + default: + log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type) + } + + c.Status(http.StatusOK) +} + +func sessionCompleted(event stripe.Event) { + customerId := event.GetObjectValue("customer") + referenceId := event.GetObjectValue("client_reference_id") + status := event.GetObjectValue("status") + if "complete" != status { + log.Println("错误的Stripe Checkout完成状态:", status, ",", referenceId) + return + } + + err := model.Recharge(referenceId, customerId) + if err != nil { + log.Println(err.Error(), referenceId) + return + } + + total, _ := strconv.ParseFloat(event.GetObjectValue("amount_total"), 64) + currency := strings.ToUpper(event.GetObjectValue("currency")) + log.Printf("收到款项:%s, %.2f(%s)", referenceId, total/100, currency) +} + +func sessionExpired(event stripe.Event) { + referenceId := event.GetObjectValue("client_reference_id") + status := event.GetObjectValue("status") + if "expired" != status { + log.Println("错误的Stripe Checkout过期状态:", status, ",", referenceId) + return + } + + if len(referenceId) == 0 { + log.Println("未提供支付单号") + return + } + + topUp := model.GetTopUpByTradeNo(referenceId) + if topUp == nil { + log.Println("充值订单不存在", referenceId) + return + } + + if topUp.Status != common.TopUpStatusPending { + log.Println("充值订单状态错误", referenceId) + } + + topUp.Status = common.TopUpStatusExpired + err := topUp.Update() + if err != nil { + log.Println("过期充值订单失败", referenceId, ", err:", err.Error()) + return + } + + log.Println("充值订单已过期", referenceId) +} + +func genStripeLink(referenceId string, customerId string, email string, amount int64) (string, error) { + if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") { + return "", fmt.Errorf("无效的Stripe API密钥") + } + + stripe.Key = setting.StripeApiSecret + + params := &stripe.CheckoutSessionParams{ + ClientReferenceID: stripe.String(referenceId), + SuccessURL: stripe.String(system_setting.ServerAddress + "/console/log"), + CancelURL: stripe.String(system_setting.ServerAddress + "/console/topup"), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(setting.StripePriceId), + Quantity: stripe.Int64(amount), + }, + }, + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + AllowPromotionCodes: stripe.Bool(setting.StripePromotionCodesEnabled), + } + + if "" == customerId { + if "" != email { + params.CustomerEmail = stripe.String(email) + } + + params.CustomerCreation = stripe.String(string(stripe.CheckoutSessionCustomerCreationAlways)) + } else { + params.Customer = stripe.String(customerId) + } + + result, err := session.New(params) + if err != nil { + return "", err + } + + return result.URL, nil +} + +func GetChargedAmount(count float64, user model.User) float64 { + topUpGroupRatio := common.GetTopupGroupRatio(user.Group) + if topUpGroupRatio == 0 { + topUpGroupRatio = 1 + } + + return count * topUpGroupRatio +} + +func getStripePayMoney(amount float64, group string) float64 { + originalAmount := amount + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + amount = amount / common.QuotaPerUnit + } + // Using float64 for monetary calculations is acceptable here due to the small amounts involved + topupGroupRatio := common.GetTopupGroupRatio(group) + if topupGroupRatio == 0 { + topupGroupRatio = 1 + } + // apply optional preset discount by the original request amount (if configured), default 1.0 + discount := 1.0 + if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok { + if ds > 0 { + discount = ds + } + } + payMoney := amount * setting.StripeUnitPrice * topupGroupRatio * discount + return payMoney +} + +func getStripeMinTopup() int64 { + minTopup := setting.StripeMinTopUp + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + minTopup = minTopup * int(common.QuotaPerUnit) + } + return int64(minTopup) +} diff --git a/controller/twofa.go b/controller/twofa.go new file mode 100644 index 0000000000000000000000000000000000000000..556c07e9e078ac8e002bd7e51e19b66296acca46 --- /dev/null +++ b/controller/twofa.go @@ -0,0 +1,554 @@ +package controller + +import ( + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +// Setup2FARequest 设置2FA请求结构 +type Setup2FARequest struct { + Code string `json:"code" binding:"required"` +} + +// Verify2FARequest 验证2FA请求结构 +type Verify2FARequest struct { + Code string `json:"code" binding:"required"` +} + +// Setup2FAResponse 设置2FA响应结构 +type Setup2FAResponse struct { + Secret string `json:"secret"` + QRCodeData string `json:"qr_code_data"` + BackupCodes []string `json:"backup_codes"` +} + +// Setup2FA 初始化2FA设置 +func Setup2FA(c *gin.Context) { + userId := c.GetInt("id") + + // 检查用户是否已经启用2FA + existing, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + if existing != nil && existing.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已启用2FA,请先禁用后重新设置", + }) + return + } + + // 如果存在已禁用的2FA记录,先删除它 + if existing != nil && !existing.IsEnabled { + if err := existing.Delete(); err != nil { + common.ApiError(c, err) + return + } + existing = nil // 重置为nil,后续将创建新记录 + } + + // 获取用户信息 + user, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + + // 生成TOTP密钥 + key, err := common.GenerateTOTPSecret(user.Username) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成2FA密钥失败", + }) + common.SysLog("生成TOTP密钥失败: " + err.Error()) + return + } + + // 生成备用码 + backupCodes, err := common.GenerateBackupCodes() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成备用码失败", + }) + common.SysLog("生成备用码失败: " + err.Error()) + return + } + + // 生成二维码数据 + qrCodeData := common.GenerateQRCodeData(key.Secret(), user.Username) + + // 创建或更新2FA记录(暂未启用) + twoFA := &model.TwoFA{ + UserId: userId, + Secret: key.Secret(), + IsEnabled: false, + } + + if existing != nil { + // 更新现有记录 + twoFA.Id = existing.Id + err = twoFA.Update() + } else { + // 创建新记录 + err = twoFA.Create() + } + + if err != nil { + common.ApiError(c, err) + return + } + + // 创建备用码记录 + if err := model.CreateBackupCodes(userId, backupCodes); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "保存备用码失败", + }) + common.SysLog("保存备用码失败: " + err.Error()) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, "开始设置两步验证") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "2FA设置初始化成功,请使用认证器扫描二维码并输入验证码完成设置", + "data": Setup2FAResponse{ + Secret: key.Secret(), + QRCodeData: qrCodeData, + BackupCodes: backupCodes, + }, + }) +} + +// Enable2FA 启用2FA +func Enable2FA(c *gin.Context) { + var req Setup2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + + userId := c.GetInt("id") + + // 获取2FA记录 + twoFA, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + if twoFA == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "请先完成2FA初始化设置", + }) + return + } + if twoFA.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "2FA已经启用", + }) + return + } + + // 验证TOTP验证码 + cleanCode, err := common.ValidateNumericCode(req.Code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + if !common.ValidateTOTPCode(twoFA.Secret, cleanCode) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码或备用码错误,请重试", + }) + return + } + + // 启用2FA + if err := twoFA.Enable(); err != nil { + common.ApiError(c, err) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, "成功启用两步验证") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "两步验证启用成功", + }) +} + +// Disable2FA 禁用2FA +func Disable2FA(c *gin.Context) { + var req Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + + userId := c.GetInt("id") + + // 获取2FA记录 + twoFA, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + if twoFA == nil || !twoFA.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户未启用2FA", + }) + return + } + + // 验证TOTP验证码或备用码 + cleanCode, err := common.ValidateNumericCode(req.Code) + isValidTOTP := false + isValidBackup := false + + if err == nil { + // 尝试验证TOTP + isValidTOTP, _ = twoFA.ValidateTOTPAndUpdateUsage(cleanCode) + } + + if !isValidTOTP { + // 尝试验证备用码 + isValidBackup, err = twoFA.ValidateBackupCodeAndUpdateUsage(req.Code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } + + if !isValidTOTP && !isValidBackup { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码或备用码错误,请重试", + }) + return + } + + // 禁用2FA + if err := model.DisableTwoFA(userId); err != nil { + common.ApiError(c, err) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, "禁用两步验证") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "两步验证已禁用", + }) +} + +// Get2FAStatus 获取用户2FA状态 +func Get2FAStatus(c *gin.Context) { + userId := c.GetInt("id") + + twoFA, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + + status := map[string]interface{}{ + "enabled": false, + "locked": false, + } + + if twoFA != nil { + status["enabled"] = twoFA.IsEnabled + status["locked"] = twoFA.IsLocked() + if twoFA.IsEnabled { + // 获取剩余备用码数量 + backupCount, err := model.GetUnusedBackupCodeCount(userId) + if err != nil { + common.SysLog("获取备用码数量失败: " + err.Error()) + } else { + status["backup_codes_remaining"] = backupCount + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": status, + }) +} + +// RegenerateBackupCodes 重新生成备用码 +func RegenerateBackupCodes(c *gin.Context) { + var req Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + + userId := c.GetInt("id") + + // 获取2FA记录 + twoFA, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + if twoFA == nil || !twoFA.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户未启用2FA", + }) + return + } + + // 验证TOTP验证码 + cleanCode, err := common.ValidateNumericCode(req.Code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + valid, err := twoFA.ValidateTOTPAndUpdateUsage(cleanCode) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + if !valid { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码或备用码错误,请重试", + }) + return + } + + // 生成新的备用码 + backupCodes, err := common.GenerateBackupCodes() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成备用码失败", + }) + common.SysLog("生成备用码失败: " + err.Error()) + return + } + + // 保存新的备用码 + if err := model.CreateBackupCodes(userId, backupCodes); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "保存备用码失败", + }) + common.SysLog("保存备用码失败: " + err.Error()) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, "重新生成两步验证备用码") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "备用码重新生成成功", + "data": map[string]interface{}{ + "backup_codes": backupCodes, + }, + }) +} + +// Verify2FALogin 登录时验证2FA +func Verify2FALogin(c *gin.Context) { + var req Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + + // 从会话中获取pending用户信息 + session := sessions.Default(c) + pendingUserId := session.Get("pending_user_id") + if pendingUserId == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "会话已过期,请重新登录", + }) + return + } + userId, ok := pendingUserId.(int) + if !ok { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "会话数据无效,请重新登录", + }) + return + } + // 获取用户信息 + user, err := model.GetUserById(userId, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户不存在", + }) + return + } + + // 获取2FA记录 + twoFA, err := model.GetTwoFAByUserId(user.Id) + if err != nil { + common.ApiError(c, err) + return + } + if twoFA == nil || !twoFA.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户未启用2FA", + }) + return + } + + // 验证TOTP验证码或备用码 + cleanCode, err := common.ValidateNumericCode(req.Code) + isValidTOTP := false + isValidBackup := false + + if err == nil { + // 尝试验证TOTP + isValidTOTP, _ = twoFA.ValidateTOTPAndUpdateUsage(cleanCode) + } + + if !isValidTOTP { + // 尝试验证备用码 + isValidBackup, err = twoFA.ValidateBackupCodeAndUpdateUsage(req.Code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } + + if !isValidTOTP && !isValidBackup { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码或备用码错误,请重试", + }) + return + } + + // 2FA验证成功,清理pending会话信息并完成登录 + session.Delete("pending_username") + session.Delete("pending_user_id") + session.Save() + + setupLogin(user, c) +} + +// Admin2FAStats 管理员获取2FA统计信息 +func Admin2FAStats(c *gin.Context) { + stats, err := model.GetTwoFAStats() + if err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": stats, + }) +} + +// AdminDisable2FA 管理员强制禁用用户2FA +func AdminDisable2FA(c *gin.Context) { + userIdStr := c.Param("id") + userId, err := strconv.Atoi(userIdStr) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户ID格式错误", + }) + return + } + + // 检查目标用户权限 + targetUser, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + + myRole := c.GetInt("role") + if myRole <= targetUser.Role && myRole != common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权操作同级或更高级用户的2FA设置", + }) + return + } + + // 禁用2FA + if err := model.DisableTwoFA(userId); err != nil { + if errors.Is(err, model.ErrTwoFANotEnabled) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户未启用2FA", + }) + return + } + common.ApiError(c, err) + return + } + + // 记录操作日志 + adminId := c.GetInt("id") + model.RecordLog(userId, model.LogTypeManage, + fmt.Sprintf("管理员(ID:%d)强制禁用了用户的两步验证", adminId)) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "用户2FA已被强制禁用", + }) +} diff --git a/controller/uptime_kuma.go b/controller/uptime_kuma.go new file mode 100644 index 0000000000000000000000000000000000000000..2beceb426f8d41630b335bd53c610fb08aaee56d --- /dev/null +++ b/controller/uptime_kuma.go @@ -0,0 +1,155 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/setting/console_setting" + + "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" +) + +const ( + requestTimeout = 30 * time.Second + httpTimeout = 10 * time.Second + uptimeKeySuffix = "_24" + apiStatusPath = "/api/status-page/" + apiHeartbeatPath = "/api/status-page/heartbeat/" +) + +type Monitor struct { + Name string `json:"name"` + Uptime float64 `json:"uptime"` + Status int `json:"status"` + Group string `json:"group,omitempty"` +} + +type UptimeGroupResult struct { + CategoryName string `json:"categoryName"` + Monitors []Monitor `json:"monitors"` +} + +func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errors.New("non-200 status") + } + + return json.NewDecoder(resp.Body).Decode(dest) +} + +func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[string]interface{}) UptimeGroupResult { + url, _ := groupConfig["url"].(string) + slug, _ := groupConfig["slug"].(string) + categoryName, _ := groupConfig["categoryName"].(string) + + result := UptimeGroupResult{ + CategoryName: categoryName, + Monitors: []Monitor{}, + } + + if url == "" || slug == "" { + return result + } + + baseURL := strings.TrimSuffix(url, "/") + + var statusData struct { + PublicGroupList []struct { + ID int `json:"id"` + Name string `json:"name"` + MonitorList []struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"monitorList"` + } `json:"publicGroupList"` + } + + var heartbeatData struct { + HeartbeatList map[string][]struct { + Status int `json:"status"` + } `json:"heartbeatList"` + UptimeList map[string]float64 `json:"uptimeList"` + } + + g, gCtx := errgroup.WithContext(ctx) + g.Go(func() error { + return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData) + }) + g.Go(func() error { + return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData) + }) + + if g.Wait() != nil { + return result + } + + for _, pg := range statusData.PublicGroupList { + if len(pg.MonitorList) == 0 { + continue + } + + for _, m := range pg.MonitorList { + monitor := Monitor{ + Name: m.Name, + Group: pg.Name, + } + + monitorID := strconv.Itoa(m.ID) + + if uptime, exists := heartbeatData.UptimeList[monitorID+uptimeKeySuffix]; exists { + monitor.Uptime = uptime + } + + if heartbeats, exists := heartbeatData.HeartbeatList[monitorID]; exists && len(heartbeats) > 0 { + monitor.Status = heartbeats[0].Status + } + + result.Monitors = append(result.Monitors, monitor) + } + } + + return result +} + +func GetUptimeKumaStatus(c *gin.Context) { + groups := console_setting.GetUptimeKumaGroups() + if len(groups) == 0 { + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": []UptimeGroupResult{}}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + + client := &http.Client{Timeout: httpTimeout} + results := make([]UptimeGroupResult, len(groups)) + + g, gCtx := errgroup.WithContext(ctx) + for i, group := range groups { + i, group := i, group + g.Go(func() error { + results[i] = fetchGroupData(gCtx, client, group) + return nil + }) + } + + g.Wait() + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results}) +} diff --git a/controller/usedata.go b/controller/usedata.go new file mode 100644 index 0000000000000000000000000000000000000000..816988a2bbcb6f3d6e61bea9c32f393574c6f925 --- /dev/null +++ b/controller/usedata.go @@ -0,0 +1,53 @@ +package controller + +import ( + "net/http" + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +func GetAllQuotaDates(c *gin.Context) { + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + username := c.Query("username") + dates, err := model.GetAllQuotaDates(startTimestamp, endTimestamp, username) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": dates, + }) + return +} + +func GetUserQuotaDates(c *gin.Context) { + userId := c.GetInt("id") + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + // 判断时间跨度是否超过 1 个月 + if endTimestamp-startTimestamp > 2592000 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "时间跨度不能超过 1 个月", + }) + return + } + dates, err := model.GetQuotaDataByUserId(userId, startTimestamp, endTimestamp) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": dates, + }) + return +} diff --git a/controller/user.go b/controller/user.go new file mode 100644 index 0000000000000000000000000000000000000000..ef4f0ddc0b0b11087b0a0d1ef4d0459bee8a8201 --- /dev/null +++ b/controller/user.go @@ -0,0 +1,1294 @@ +package controller + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting" + + "github.com/QuantumNous/new-api/constant" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func Login(c *gin.Context) { + if !common.PasswordLoginEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "管理员关闭了密码登录", + "success": false, + }) + return + } + var loginRequest LoginRequest + err := json.NewDecoder(c.Request.Body).Decode(&loginRequest) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": "无效的参数", + "success": false, + }) + return + } + username := loginRequest.Username + password := loginRequest.Password + if username == "" || password == "" { + c.JSON(http.StatusOK, gin.H{ + "message": "无效的参数", + "success": false, + }) + return + } + user := model.User{ + Username: username, + Password: password, + } + err = user.ValidateAndFill() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + + // 检查是否启用2FA + if model.IsTwoFAEnabled(user.Id) { + // 设置pending session,等待2FA验证 + session := sessions.Default(c) + session.Set("pending_username", user.Username) + session.Set("pending_user_id", user.Id) + err := session.Save() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": "无法保存会话信息,请重试", + "success": false, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "请输入两步验证码", + "success": true, + "data": map[string]interface{}{ + "require_2fa": true, + }, + }) + return + } + + setupLogin(&user, c) +} + +// setup session & cookies and then return user info +func setupLogin(user *model.User, c *gin.Context) { + session := sessions.Default(c) + session.Set("id", user.Id) + session.Set("username", user.Username) + session.Set("role", user.Role) + session.Set("status", user.Status) + session.Set("group", user.Group) + err := session.Save() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": "无法保存会话信息,请重试", + "success": false, + }) + return + } + cleanUser := model.User{ + Id: user.Id, + Username: user.Username, + DisplayName: user.DisplayName, + Role: user.Role, + Status: user.Status, + Group: user.Group, + } + c.JSON(http.StatusOK, gin.H{ + "message": "", + "success": true, + "data": cleanUser, + }) +} + +func Logout(c *gin.Context) { + session := sessions.Default(c) + session.Clear() + err := session.Save() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "", + "success": true, + }) +} + +func Register(c *gin.Context) { + if !common.RegisterEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "管理员关闭了新用户注册", + "success": false, + }) + return + } + if !common.PasswordRegisterEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "管理员关闭了通过密码进行注册,请使用第三方账户验证的形式进行注册", + "success": false, + }) + return + } + var user model.User + err := json.NewDecoder(c.Request.Body).Decode(&user) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + if err := common.Validate.Struct(&user); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "输入不合法 " + err.Error(), + }) + return + } + if common.EmailVerificationEnabled { + if user.Email == "" || user.VerificationCode == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员开启了邮箱验证,请输入邮箱地址和验证码", + }) + return + } + if !common.VerifyCodeWithKey(user.Email, user.VerificationCode, common.EmailVerificationPurpose) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码错误或已过期", + }) + return + } + } + exist, err := model.CheckUserExistOrDeleted(user.Username, user.Email) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "数据库错误,请稍后重试", + }) + common.SysLog(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err)) + return + } + if exist { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户名已存在,或已注销", + }) + return + } + affCode := user.AffCode // this code is the inviter's code, not the user's own code + inviterId, _ := model.GetUserIdByAffCode(affCode) + cleanUser := model.User{ + Username: user.Username, + Password: user.Password, + DisplayName: user.Username, + InviterId: inviterId, + Role: common.RoleCommonUser, // 明确设置角色为普通用户 + } + if common.EmailVerificationEnabled { + cleanUser.Email = user.Email + } + if err := cleanUser.Insert(inviterId); err != nil { + common.ApiError(c, err) + return + } + + // 获取插入后的用户ID + var insertedUser model.User + if err := model.DB.Where("username = ?", cleanUser.Username).First(&insertedUser).Error; err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户注册失败或用户ID获取失败", + }) + return + } + // 生成默认令牌 + if constant.GenerateDefaultToken { + key, err := common.GenerateKey() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成默认令牌失败", + }) + common.SysLog("failed to generate token key: " + err.Error()) + return + } + // 生成默认令牌 + token := model.Token{ + UserId: insertedUser.Id, // 使用插入后的用户ID + Name: cleanUser.Username + "的初始令牌", + Key: key, + CreatedTime: common.GetTimestamp(), + AccessedTime: common.GetTimestamp(), + ExpiredTime: -1, // 永不过期 + RemainQuota: 500000, // 示例额度 + UnlimitedQuota: true, + ModelLimitsEnabled: false, + } + if setting.DefaultUseAutoGroup { + token.Group = "auto" + } + if err := token.Insert(); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "创建默认令牌失败", + }) + return + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func GetAllUsers(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + users, total, err := model.GetAllUsers(pageInfo) + if err != nil { + common.ApiError(c, err) + return + } + + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(users) + + common.ApiSuccess(c, pageInfo) + return +} + +func SearchUsers(c *gin.Context) { + keyword := c.Query("keyword") + group := c.Query("group") + pageInfo := common.GetPageQuery(c) + users, total, err := model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(users) + common.ApiSuccess(c, pageInfo) + return +} + +func GetUser(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + user, err := model.GetUserById(id, false) + if err != nil { + common.ApiError(c, err) + return + } + myRole := c.GetInt("role") + if myRole <= user.Role && myRole != common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权获取同级或更高等级用户的信息", + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": user, + }) + return +} + +func GenerateAccessToken(c *gin.Context) { + id := c.GetInt("id") + user, err := model.GetUserById(id, true) + if err != nil { + common.ApiError(c, err) + return + } + // get rand int 28-32 + randI := common.GetRandomInt(4) + key, err := common.GenerateRandomKey(29 + randI) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成失败", + }) + common.SysLog("failed to generate key: " + err.Error()) + return + } + user.SetAccessToken(key) + + if model.DB.Where("access_token = ?", user.AccessToken).First(user).RowsAffected != 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "请重试,系统生成的 UUID 竟然重复了!", + }) + return + } + + if err := user.Update(false); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": user.AccessToken, + }) + return +} + +type TransferAffQuotaRequest struct { + Quota int `json:"quota" binding:"required"` +} + +func TransferAffQuota(c *gin.Context) { + id := c.GetInt("id") + user, err := model.GetUserById(id, true) + if err != nil { + common.ApiError(c, err) + return + } + tran := TransferAffQuotaRequest{} + if err := c.ShouldBindJSON(&tran); err != nil { + common.ApiError(c, err) + return + } + err = user.TransferAffQuotaToQuota(tran.Quota) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "划转失败 " + err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "划转成功", + }) +} + +func GetAffCode(c *gin.Context) { + id := c.GetInt("id") + user, err := model.GetUserById(id, true) + if err != nil { + common.ApiError(c, err) + return + } + if user.AffCode == "" { + user.AffCode = common.GetRandomString(4) + if err := user.Update(false); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": user.AffCode, + }) + return +} + +func GetSelf(c *gin.Context) { + id := c.GetInt("id") + userRole := c.GetInt("role") + user, err := model.GetUserById(id, false) + if err != nil { + common.ApiError(c, err) + return + } + // Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users + user.Remark = "" + + // 计算用户权限信息 + permissions := calculateUserPermissions(userRole) + + // 获取用户设置并提取sidebar_modules + userSetting := user.GetSetting() + + // 构建响应数据,包含用户信息和权限 + responseData := map[string]interface{}{ + "id": user.Id, + "username": user.Username, + "display_name": user.DisplayName, + "role": user.Role, + "status": user.Status, + "email": user.Email, + "github_id": user.GitHubId, + "discord_id": user.DiscordId, + "oidc_id": user.OidcId, + "wechat_id": user.WeChatId, + "telegram_id": user.TelegramId, + "group": user.Group, + "quota": user.Quota, + "used_quota": user.UsedQuota, + "request_count": user.RequestCount, + "aff_code": user.AffCode, + "aff_count": user.AffCount, + "aff_quota": user.AffQuota, + "aff_history_quota": user.AffHistoryQuota, + "inviter_id": user.InviterId, + "linux_do_id": user.LinuxDOId, + "setting": user.Setting, + "stripe_customer": user.StripeCustomer, + "sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段 + "permissions": permissions, // 新增权限字段 + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": responseData, + }) + return +} + +// 计算用户权限的辅助函数 +func calculateUserPermissions(userRole int) map[string]interface{} { + permissions := map[string]interface{}{} + + // 根据用户角色计算权限 + if userRole == common.RoleRootUser { + // 超级管理员不需要边栏设置功能 + permissions["sidebar_settings"] = false + permissions["sidebar_modules"] = map[string]interface{}{} + } else if userRole == common.RoleAdminUser { + // 管理员可以设置边栏,但不包含系统设置功能 + permissions["sidebar_settings"] = true + permissions["sidebar_modules"] = map[string]interface{}{ + "admin": map[string]interface{}{ + "setting": false, // 管理员不能访问系统设置 + }, + } + } else { + // 普通用户只能设置个人功能,不包含管理员区域 + permissions["sidebar_settings"] = true + permissions["sidebar_modules"] = map[string]interface{}{ + "admin": false, // 普通用户不能访问管理员区域 + } + } + + return permissions +} + +// 根据用户角色生成默认的边栏配置 +func generateDefaultSidebarConfig(userRole int) string { + defaultConfig := map[string]interface{}{} + + // 聊天区域 - 所有用户都可以访问 + defaultConfig["chat"] = map[string]interface{}{ + "enabled": true, + "playground": true, + "chat": true, + } + + // 控制台区域 - 所有用户都可以访问 + defaultConfig["console"] = map[string]interface{}{ + "enabled": true, + "detail": true, + "token": true, + "log": true, + "midjourney": true, + "task": true, + } + + // 个人中心区域 - 所有用户都可以访问 + defaultConfig["personal"] = map[string]interface{}{ + "enabled": true, + "topup": true, + "personal": true, + } + + // 管理员区域 - 根据角色决定 + if userRole == common.RoleAdminUser { + // 管理员可以访问管理员区域,但不能访问系统设置 + defaultConfig["admin"] = map[string]interface{}{ + "enabled": true, + "channel": true, + "models": true, + "redemption": true, + "user": true, + "setting": false, // 管理员不能访问系统设置 + } + } else if userRole == common.RoleRootUser { + // 超级管理员可以访问所有功能 + defaultConfig["admin"] = map[string]interface{}{ + "enabled": true, + "channel": true, + "models": true, + "redemption": true, + "user": true, + "setting": true, + } + } + // 普通用户不包含admin区域 + + // 转换为JSON字符串 + configBytes, err := json.Marshal(defaultConfig) + if err != nil { + common.SysLog("生成默认边栏配置失败: " + err.Error()) + return "" + } + + return string(configBytes) +} + +func GetUserModels(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + id = c.GetInt("id") + } + user, err := model.GetUserCache(id) + if err != nil { + common.ApiError(c, err) + return + } + groups := service.GetUserUsableGroups(user.Group) + var models []string + for group := range groups { + for _, g := range model.GetGroupEnabledModels(group) { + if !common.StringsContains(models, g) { + models = append(models, g) + } + } + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": models, + }) + return +} + +func UpdateUser(c *gin.Context) { + var updatedUser model.User + err := json.NewDecoder(c.Request.Body).Decode(&updatedUser) + if err != nil || updatedUser.Id == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + if updatedUser.Password == "" { + updatedUser.Password = "$I_LOVE_U" // make Validator happy :) + } + if err := common.Validate.Struct(&updatedUser); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "输入不合法 " + err.Error(), + }) + return + } + originUser, err := model.GetUserById(updatedUser.Id, false) + if err != nil { + common.ApiError(c, err) + return + } + myRole := c.GetInt("role") + if myRole <= originUser.Role && myRole != common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权更新同权限等级或更高权限等级的用户信息", + }) + return + } + if myRole <= updatedUser.Role && myRole != common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权将其他用户权限等级提升到大于等于自己的权限等级", + }) + return + } + if updatedUser.Password == "$I_LOVE_U" { + updatedUser.Password = "" // rollback to what it should be + } + updatePassword := updatedUser.Password != "" + if err := updatedUser.Edit(updatePassword); err != nil { + common.ApiError(c, err) + return + } + if originUser.Quota != updatedUser.Quota { + model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota))) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func UpdateSelf(c *gin.Context) { + var requestData map[string]interface{} + err := json.NewDecoder(c.Request.Body).Decode(&requestData) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + // 检查是否是sidebar_modules更新请求 + if sidebarModules, exists := requestData["sidebar_modules"]; exists { + userId := c.GetInt("id") + user, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + + // 获取当前用户设置 + currentSetting := user.GetSetting() + + // 更新sidebar_modules字段 + if sidebarModulesStr, ok := sidebarModules.(string); ok { + currentSetting.SidebarModules = sidebarModulesStr + } + + // 保存更新后的设置 + user.SetSetting(currentSetting) + if err := user.Update(false); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "更新设置失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "设置更新成功", + }) + return + } + + // 原有的用户信息更新逻辑 + var user model.User + requestDataBytes, err := json.Marshal(requestData) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + err = json.Unmarshal(requestDataBytes, &user) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + if user.Password == "" { + user.Password = "$I_LOVE_U" // make Validator happy :) + } + if err := common.Validate.Struct(&user); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "输入不合法 " + err.Error(), + }) + return + } + + cleanUser := model.User{ + Id: c.GetInt("id"), + Username: user.Username, + Password: user.Password, + DisplayName: user.DisplayName, + } + if user.Password == "$I_LOVE_U" { + user.Password = "" // rollback to what it should be + cleanUser.Password = "" + } + updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id) + if err != nil { + common.ApiError(c, err) + return + } + if err := cleanUser.Update(updatePassword); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func checkUpdatePassword(originalPassword string, newPassword string, userId int) (updatePassword bool, err error) { + var currentUser *model.User + currentUser, err = model.GetUserById(userId, true) + if err != nil { + return + } + if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) { + err = fmt.Errorf("原密码错误") + return + } + if newPassword == "" { + return + } + updatePassword = true + return +} + +func DeleteUser(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + originUser, err := model.GetUserById(id, false) + if err != nil { + common.ApiError(c, err) + return + } + myRole := c.GetInt("role") + if myRole <= originUser.Role { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权删除同权限等级或更高权限等级的用户", + }) + return + } + err = model.HardDeleteUserById(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return + } +} + +func DeleteSelf(c *gin.Context) { + id := c.GetInt("id") + user, _ := model.GetUserById(id, false) + + if user.Role == common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "不能删除超级管理员账户", + }) + return + } + + err := model.DeleteUserById(id) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func CreateUser(c *gin.Context) { + var user model.User + err := json.NewDecoder(c.Request.Body).Decode(&user) + user.Username = strings.TrimSpace(user.Username) + if err != nil || user.Username == "" || user.Password == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + if err := common.Validate.Struct(&user); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "输入不合法 " + err.Error(), + }) + return + } + if user.DisplayName == "" { + user.DisplayName = user.Username + } + myRole := c.GetInt("role") + if user.Role >= myRole { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法创建权限大于等于自己的用户", + }) + return + } + // Even for admin users, we cannot fully trust them! + cleanUser := model.User{ + Username: user.Username, + Password: user.Password, + DisplayName: user.DisplayName, + Role: user.Role, // 保持管理员设置的角色 + } + if err := cleanUser.Insert(0); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +type ManageRequest struct { + Id int `json:"id"` + Action string `json:"action"` +} + +// ManageUser Only admin user can do this +func ManageUser(c *gin.Context) { + var req ManageRequest + err := json.NewDecoder(c.Request.Body).Decode(&req) + + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + user := model.User{ + Id: req.Id, + } + // Fill attributes + model.DB.Unscoped().Where(&user).First(&user) + if user.Id == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户不存在", + }) + return + } + myRole := c.GetInt("role") + if myRole <= user.Role && myRole != common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权更新同权限等级或更高权限等级的用户信息", + }) + return + } + switch req.Action { + case "disable": + user.Status = common.UserStatusDisabled + if user.Role == common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法禁用超级管理员用户", + }) + return + } + case "enable": + user.Status = common.UserStatusEnabled + case "delete": + if user.Role == common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法删除超级管理员用户", + }) + return + } + if err := user.Delete(); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "promote": + if myRole != common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "普通管理员用户无法提升其他用户为管理员", + }) + return + } + if user.Role >= common.RoleAdminUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户已经是管理员", + }) + return + } + user.Role = common.RoleAdminUser + case "demote": + if user.Role == common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法降级超级管理员用户", + }) + return + } + if user.Role == common.RoleCommonUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户已经是普通用户", + }) + return + } + user.Role = common.RoleCommonUser + } + + if err := user.Update(false); err != nil { + common.ApiError(c, err) + return + } + clearUser := model.User{ + Role: user.Role, + Status: user.Status, + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": clearUser, + }) + return +} + +func EmailBind(c *gin.Context) { + email := c.Query("email") + code := c.Query("code") + if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码错误或已过期", + }) + return + } + session := sessions.Default(c) + id := session.Get("id") + user := model.User{ + Id: id.(int), + } + err := user.FillUserById() + if err != nil { + common.ApiError(c, err) + return + } + user.Email = email + // no need to check if this email already taken, because we have used verification code to check it + err = user.Update(false) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +type topUpRequest struct { + Key string `json:"key"` +} + +var topUpLocks sync.Map +var topUpCreateLock sync.Mutex + +type topUpTryLock struct { + ch chan struct{} +} + +func newTopUpTryLock() *topUpTryLock { + return &topUpTryLock{ch: make(chan struct{}, 1)} +} + +func (l *topUpTryLock) TryLock() bool { + select { + case l.ch <- struct{}{}: + return true + default: + return false + } +} + +func (l *topUpTryLock) Unlock() { + select { + case <-l.ch: + default: + } +} + +func getTopUpLock(userID int) *topUpTryLock { + if v, ok := topUpLocks.Load(userID); ok { + return v.(*topUpTryLock) + } + topUpCreateLock.Lock() + defer topUpCreateLock.Unlock() + if v, ok := topUpLocks.Load(userID); ok { + return v.(*topUpTryLock) + } + l := newTopUpTryLock() + topUpLocks.Store(userID, l) + return l +} + +func TopUp(c *gin.Context) { + id := c.GetInt("id") + lock := getTopUpLock(id) + if !lock.TryLock() { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "充值处理中,请稍后重试", + }) + return + } + defer lock.Unlock() + req := topUpRequest{} + err := c.ShouldBindJSON(&req) + if err != nil { + common.ApiError(c, err) + return + } + quota, err := model.Redeem(req.Key, id) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": quota, + }) +} + +type UpdateUserSettingRequest struct { + QuotaWarningType string `json:"notify_type"` + QuotaWarningThreshold float64 `json:"quota_warning_threshold"` + WebhookUrl string `json:"webhook_url,omitempty"` + WebhookSecret string `json:"webhook_secret,omitempty"` + NotificationEmail string `json:"notification_email,omitempty"` + BarkUrl string `json:"bark_url,omitempty"` + GotifyUrl string `json:"gotify_url,omitempty"` + GotifyToken string `json:"gotify_token,omitempty"` + GotifyPriority int `json:"gotify_priority,omitempty"` + AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"` + RecordIpLog bool `json:"record_ip_log"` +} + +func UpdateUserSetting(c *gin.Context) { + var req UpdateUserSettingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + // 验证预警类型 + if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的预警类型", + }) + return + } + + // 验证预警阈值 + if req.QuotaWarningThreshold <= 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "预警阈值必须大于0", + }) + return + } + + // 如果是webhook类型,验证webhook地址 + if req.QuotaWarningType == dto.NotifyTypeWebhook { + if req.WebhookUrl == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Webhook地址不能为空", + }) + return + } + // 验证URL格式 + if _, err := url.ParseRequestURI(req.WebhookUrl); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的Webhook地址", + }) + return + } + } + + // 如果是邮件类型,验证邮箱地址 + if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" { + // 验证邮箱格式 + if !strings.Contains(req.NotificationEmail, "@") { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的邮箱地址", + }) + return + } + } + + // 如果是Bark类型,验证Bark URL + if req.QuotaWarningType == dto.NotifyTypeBark { + if req.BarkUrl == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Bark推送URL不能为空", + }) + return + } + // 验证URL格式 + if _, err := url.ParseRequestURI(req.BarkUrl); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的Bark推送URL", + }) + return + } + // 检查是否是HTTP或HTTPS + if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Bark推送URL必须以http://或https://开头", + }) + return + } + } + + // 如果是Gotify类型,验证Gotify URL和Token + if req.QuotaWarningType == dto.NotifyTypeGotify { + if req.GotifyUrl == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify服务器地址不能为空", + }) + return + } + if req.GotifyToken == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify令牌不能为空", + }) + return + } + // 验证URL格式 + if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的Gotify服务器地址", + }) + return + } + // 检查是否是HTTP或HTTPS + if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify服务器地址必须以http://或https://开头", + }) + return + } + } + + userId := c.GetInt("id") + user, err := model.GetUserById(userId, true) + if err != nil { + common.ApiError(c, err) + return + } + + // 构建设置 + settings := dto.UserSetting{ + NotifyType: req.QuotaWarningType, + QuotaWarningThreshold: req.QuotaWarningThreshold, + AcceptUnsetRatioModel: req.AcceptUnsetModelRatioModel, + RecordIpLog: req.RecordIpLog, + } + + // 如果是webhook类型,添加webhook相关设置 + if req.QuotaWarningType == dto.NotifyTypeWebhook { + settings.WebhookUrl = req.WebhookUrl + if req.WebhookSecret != "" { + settings.WebhookSecret = req.WebhookSecret + } + } + + // 如果提供了通知邮箱,添加到设置中 + if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" { + settings.NotificationEmail = req.NotificationEmail + } + + // 如果是Bark类型,添加Bark URL到设置中 + if req.QuotaWarningType == dto.NotifyTypeBark { + settings.BarkUrl = req.BarkUrl + } + + // 如果是Gotify类型,添加Gotify配置到设置中 + if req.QuotaWarningType == dto.NotifyTypeGotify { + settings.GotifyUrl = req.GotifyUrl + settings.GotifyToken = req.GotifyToken + // Gotify优先级范围0-10,超出范围则使用默认值5 + if req.GotifyPriority < 0 || req.GotifyPriority > 10 { + settings.GotifyPriority = 5 + } else { + settings.GotifyPriority = req.GotifyPriority + } + } + + // 更新用户设置 + user.SetSetting(settings) + if err := user.Update(false); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "更新设置失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "设置已更新", + }) +} diff --git a/controller/vendor_meta.go b/controller/vendor_meta.go new file mode 100644 index 0000000000000000000000000000000000000000..243ed1862880bbe9da0f181c9118653bf454ccdd --- /dev/null +++ b/controller/vendor_meta.go @@ -0,0 +1,124 @@ +package controller + +import ( + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +// GetAllVendors 获取供应商列表(分页) +func GetAllVendors(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + var total int64 + model.DB.Model(&model.Vendor{}).Count(&total) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(vendors) + common.ApiSuccess(c, pageInfo) +} + +// SearchVendors 搜索供应商 +func SearchVendors(c *gin.Context) { + keyword := c.Query("keyword") + pageInfo := common.GetPageQuery(c) + vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(vendors) + common.ApiSuccess(c, pageInfo) +} + +// GetVendorMeta 根据 ID 获取供应商 +func GetVendorMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + v, err := model.GetVendorByID(id) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, v) +} + +// CreateVendorMeta 新建供应商 +func CreateVendorMeta(c *gin.Context) { + var v model.Vendor + if err := c.ShouldBindJSON(&v); err != nil { + common.ApiError(c, err) + return + } + if v.Name == "" { + common.ApiErrorMsg(c, "供应商名称不能为空") + return + } + // 创建前先检查名称 + if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "供应商名称已存在") + return + } + + if err := v.Insert(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &v) +} + +// UpdateVendorMeta 更新供应商 +func UpdateVendorMeta(c *gin.Context) { + var v model.Vendor + if err := c.ShouldBindJSON(&v); err != nil { + common.ApiError(c, err) + return + } + if v.Id == 0 { + common.ApiErrorMsg(c, "缺少供应商 ID") + return + } + // 名称冲突检查 + if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "供应商名称已存在") + return + } + + if err := v.Update(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &v) +} + +// DeleteVendorMeta 删除供应商 +func DeleteVendorMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} diff --git a/controller/video_proxy.go b/controller/video_proxy.go new file mode 100644 index 0000000000000000000000000000000000000000..829a94c1b3392c861ceece75bf24b74427bff6c9 --- /dev/null +++ b/controller/video_proxy.go @@ -0,0 +1,177 @@ +package controller + +import ( + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +func VideoProxy(c *gin.Context) { + taskID := c.Param("task_id") + if taskID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": gin.H{ + "message": "task_id is required", + "type": "invalid_request_error", + }, + }) + return + } + + task, exists, err := model.GetByOnlyTaskId(taskID) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to query task %s: %s", taskID, err.Error())) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "message": "Failed to query task", + "type": "server_error", + }, + }) + return + } + if !exists || task == nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: %v", taskID, err)) + c.JSON(http.StatusNotFound, gin.H{ + "error": gin.H{ + "message": "Task not found", + "type": "invalid_request_error", + }, + }) + return + } + + if task.Status != model.TaskStatusSuccess { + c.JSON(http.StatusBadRequest, gin.H{ + "error": gin.H{ + "message": fmt.Sprintf("Task is not completed yet, current status: %s", task.Status), + "type": "invalid_request_error", + }, + }) + return + } + + channel, err := model.CacheGetChannel(task.ChannelId) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: not found", taskID)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "message": "Failed to retrieve channel information", + "type": "server_error", + }, + }) + return + } + baseURL := channel.GetBaseURL() + if baseURL == "" { + baseURL = "https://api.openai.com" + } + + var videoURL string + client := &http.Client{ + Timeout: 60 * time.Second, + } + + req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, "", nil) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create request: %s", err.Error())) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "message": "Failed to create proxy request", + "type": "server_error", + }, + }) + return + } + + switch channel.Type { + case constant.ChannelTypeGemini: + apiKey := task.PrivateData.Key + if apiKey == "" { + logger.LogError(c.Request.Context(), fmt.Sprintf("Missing stored API key for Gemini task %s", taskID)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "message": "API key not stored for task", + "type": "server_error", + }, + }) + return + } + + videoURL, err = getGeminiVideoURL(channel, task, apiKey) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to resolve Gemini video URL for task %s: %s", taskID, err.Error())) + c.JSON(http.StatusBadGateway, gin.H{ + "error": gin.H{ + "message": "Failed to resolve Gemini video URL", + "type": "server_error", + }, + }) + return + } + req.Header.Set("x-goog-api-key", apiKey) + case constant.ChannelTypeAli: + // Video URL is directly in task.FailReason + videoURL = task.FailReason + default: + // Default (Sora, etc.): Use original logic + videoURL = fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID) + req.Header.Set("Authorization", "Bearer "+channel.Key) + } + + req.URL, err = url.Parse(videoURL) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", videoURL, err.Error())) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "message": "Failed to create proxy request", + "type": "server_error", + }, + }) + return + } + + resp, err := client.Do(req) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to fetch video from %s: %s", videoURL, err.Error())) + c.JSON(http.StatusBadGateway, gin.H{ + "error": gin.H{ + "message": "Failed to fetch video content", + "type": "server_error", + }, + }) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.LogError(c.Request.Context(), fmt.Sprintf("Upstream returned status %d for %s", resp.StatusCode, videoURL)) + c.JSON(http.StatusBadGateway, gin.H{ + "error": gin.H{ + "message": fmt.Sprintf("Upstream service returned status %d", resp.StatusCode), + "type": "server_error", + }, + }) + return + } + + for key, values := range resp.Header { + for _, value := range values { + c.Writer.Header().Add(key, value) + } + } + + c.Writer.Header().Set("Cache-Control", "public, max-age=86400") // Cache for 24 hours + c.Writer.WriteHeader(resp.StatusCode) + _, err = io.Copy(c.Writer, resp.Body) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to stream video content: %s", err.Error())) + } +} diff --git a/controller/video_proxy_gemini.go b/controller/video_proxy_gemini.go new file mode 100644 index 0000000000000000000000000000000000000000..4e2e60e62fa0a77ee27dbca335f811f4dda72abb --- /dev/null +++ b/controller/video_proxy_gemini.go @@ -0,0 +1,158 @@ +package controller + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay" +) + +func getGeminiVideoURL(channel *model.Channel, task *model.Task, apiKey string) (string, error) { + if channel == nil || task == nil { + return "", fmt.Errorf("invalid channel or task") + } + + if url := extractGeminiVideoURLFromTaskData(task); url != "" { + return ensureAPIKey(url, apiKey), nil + } + + baseURL := constant.ChannelBaseURLs[channel.Type] + if channel.GetBaseURL() != "" { + baseURL = channel.GetBaseURL() + } + + adaptor := relay.GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channel.Type))) + if adaptor == nil { + return "", fmt.Errorf("gemini task adaptor not found") + } + + if apiKey == "" { + return "", fmt.Errorf("api key not available for task") + } + + resp, err := adaptor.FetchTask(baseURL, apiKey, map[string]any{ + "task_id": task.TaskID, + "action": task.Action, + }) + if err != nil { + return "", fmt.Errorf("fetch task failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read task response failed: %w", err) + } + + taskInfo, parseErr := adaptor.ParseTaskResult(body) + if parseErr == nil && taskInfo != nil && taskInfo.RemoteUrl != "" { + return ensureAPIKey(taskInfo.RemoteUrl, apiKey), nil + } + + if url := extractGeminiVideoURLFromPayload(body); url != "" { + return ensureAPIKey(url, apiKey), nil + } + + if parseErr != nil { + return "", fmt.Errorf("parse task result failed: %w", parseErr) + } + + return "", fmt.Errorf("gemini video url not found") +} + +func extractGeminiVideoURLFromTaskData(task *model.Task) string { + if task == nil || len(task.Data) == 0 { + return "" + } + var payload map[string]any + if err := json.Unmarshal(task.Data, &payload); err != nil { + return "" + } + return extractGeminiVideoURLFromMap(payload) +} + +func extractGeminiVideoURLFromPayload(body []byte) string { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + return extractGeminiVideoURLFromMap(payload) +} + +func extractGeminiVideoURLFromMap(payload map[string]any) string { + if payload == nil { + return "" + } + if uri, ok := payload["uri"].(string); ok && uri != "" { + return uri + } + if resp, ok := payload["response"].(map[string]any); ok { + if uri := extractGeminiVideoURLFromResponse(resp); uri != "" { + return uri + } + } + return "" +} + +func extractGeminiVideoURLFromResponse(resp map[string]any) string { + if resp == nil { + return "" + } + if gvr, ok := resp["generateVideoResponse"].(map[string]any); ok { + if uri := extractGeminiVideoURLFromGeneratedSamples(gvr); uri != "" { + return uri + } + } + if videos, ok := resp["videos"].([]any); ok { + for _, video := range videos { + if vm, ok := video.(map[string]any); ok { + if uri, ok := vm["uri"].(string); ok && uri != "" { + return uri + } + } + } + } + if uri, ok := resp["video"].(string); ok && uri != "" { + return uri + } + if uri, ok := resp["uri"].(string); ok && uri != "" { + return uri + } + return "" +} + +func extractGeminiVideoURLFromGeneratedSamples(gvr map[string]any) string { + if gvr == nil { + return "" + } + if samples, ok := gvr["generatedSamples"].([]any); ok { + for _, sample := range samples { + if sm, ok := sample.(map[string]any); ok { + if video, ok := sm["video"].(map[string]any); ok { + if uri, ok := video["uri"].(string); ok && uri != "" { + return uri + } + } + } + } + } + return "" +} + +func ensureAPIKey(uri, key string) string { + if key == "" || uri == "" { + return uri + } + if strings.Contains(uri, "key=") { + return uri + } + if strings.Contains(uri, "?") { + return fmt.Sprintf("%s&key=%s", uri, key) + } + return fmt.Sprintf("%s?key=%s", uri, key) +} diff --git a/controller/wechat.go b/controller/wechat.go new file mode 100644 index 0000000000000000000000000000000000000000..07f2fb32e8449fccaa04e605ee12dd11a416b698 --- /dev/null +++ b/controller/wechat.go @@ -0,0 +1,169 @@ +package controller + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type wechatLoginResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data string `json:"data"` +} + +func getWeChatIdByCode(code string) (string, error) { + if code == "" { + return "", errors.New("无效的参数") + } + req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, code), nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", common.WeChatServerToken) + client := http.Client{ + Timeout: 5 * time.Second, + } + httpResponse, err := client.Do(req) + if err != nil { + return "", err + } + defer httpResponse.Body.Close() + var res wechatLoginResponse + err = json.NewDecoder(httpResponse.Body).Decode(&res) + if err != nil { + return "", err + } + if !res.Success { + return "", errors.New(res.Message) + } + if res.Data == "" { + return "", errors.New("验证码错误或已过期") + } + return res.Data, nil +} + +func WeChatAuth(c *gin.Context) { + if !common.WeChatAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "管理员未开启通过微信登录以及注册", + "success": false, + }) + return + } + code := c.Query("code") + wechatId, err := getWeChatIdByCode(code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + user := model.User{ + WeChatId: wechatId, + } + if model.IsWeChatIdAlreadyTaken(wechatId) { + err := user.FillUserByWeChatId() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + if user.Id == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已注销", + }) + return + } + } else { + if common.RegisterEnabled { + user.Username = "wechat_" + strconv.Itoa(model.GetMaxUserId()+1) + user.DisplayName = "WeChat User" + user.Role = common.RoleCommonUser + user.Status = common.UserStatusEnabled + + if err := user.Insert(0); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员关闭了新用户注册", + }) + return + } + } + + if user.Status != common.UserStatusEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "用户已被封禁", + "success": false, + }) + return + } + setupLogin(&user, c) +} + +func WeChatBind(c *gin.Context) { + if !common.WeChatAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "管理员未开启通过微信登录以及注册", + "success": false, + }) + return + } + code := c.Query("code") + wechatId, err := getWeChatIdByCode(code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + if model.IsWeChatIdAlreadyTaken(wechatId) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该微信账号已被绑定", + }) + return + } + session := sessions.Default(c) + id := session.Get("id") + user := model.User{ + Id: id.(int), + } + err = user.FillUserById() + if err != nil { + common.ApiError(c, err) + return + } + user.WeChatId = wechatId + err = user.Update(false) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..a9d00967cf49ebbd0c0952af1fdf41d185b786a7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,84 @@ +# New-API Docker Compose Configuration +# +# Quick Start: +# 1. docker-compose up -d +# 2. Access at http://localhost:3000 +# +# Using MySQL instead of PostgreSQL: +# 1. Comment out the postgres service and SQL_DSN line 15 +# 2. Uncomment the mysql service and SQL_DSN line 16 +# 3. Uncomment mysql in depends_on (line 28) +# 4. Uncomment mysql_data in volumes section (line 64) +# +# ⚠️ IMPORTANT: Change all default passwords before deploying to production! + +version: '3.4' # For compatibility with older Docker versions + +services: + new-api: + image: calciumion/new-api:latest + container_name: new-api + restart: always + command: --log-dir /app/logs + ports: + - "3000:3000" + volumes: + - ./data:/data + - ./logs:/app/logs + environment: + - SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production! +# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL + - REDIS_CONN_STRING=redis://redis + - TZ=Asia/Shanghai + - ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording) + - BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update) +# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions) +# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!) +# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed +# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID) +# - UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Umami 网站 ID (Umami Website ID) +# - UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js # Umami 脚本 URL,默认为官方地址 (Umami Script URL, defaults to official URL) + + depends_on: + - redis + - postgres +# - mysql # Uncomment if using MySQL + healthcheck: + test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + + redis: + image: redis:latest + container_name: redis + restart: always + + postgres: + image: postgres:15 + container_name: postgres + restart: always + environment: + POSTGRES_USER: root + POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! + POSTGRES_DB: new-api + volumes: + - pg_data:/var/lib/postgresql/data +# ports: +# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker + +# mysql: +# image: mysql:8.2 +# container_name: mysql +# restart: always +# environment: +# MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! +# MYSQL_DATABASE: new-api +# volumes: +# - mysql_data:/var/lib/mysql +# ports: +# - "3306:3306" # Uncomment if you need to access MySQL from outside Docker + +volumes: + pg_data: +# mysql_data: diff --git a/docs/api/api_auth.md b/docs/api/api_auth.md new file mode 100644 index 0000000000000000000000000000000000000000..798ca3741196d6a0bb1fcc4f8ef38ac0fa9177c9 --- /dev/null +++ b/docs/api/api_auth.md @@ -0,0 +1,53 @@ +# API 鉴权文档 + +## 认证方式 + +### Access Token + +对于需要鉴权的 API 接口,必须同时提供以下两个请求头来进行 Access Token 认证: + +1. **请求头中的 `Authorization` 字段** + + 将 Access Token 放置于 HTTP 请求头部的 `Authorization` 字段中,格式如下: + + ``` + Authorization: + ``` + + 其中 `` 需要替换为实际的 Access Token 值。 + +2. **请求头中的 `New-Api-User` 字段** + + 将用户 ID 放置于 HTTP 请求头部的 `New-Api-User` 字段中,格式如下: + + ``` + New-Api-User: + ``` + + 其中 `` 需要替换为实际的用户 ID。 + +**注意:** + +* **必须同时提供 `Authorization` 和 `New-Api-User` 两个请求头才能通过鉴权。** +* 如果只提供其中一个请求头,或者两个请求头都未提供,则会返回 `401 Unauthorized` 错误。 +* 如果 `Authorization` 中的 Access Token 无效,则会返回 `401 Unauthorized` 错误,并提示“无权进行此操作,access token 无效”。 +* 如果 `New-Api-User` 中的用户 ID 与 Access Token 不匹配,则会返回 `401 Unauthorized` 错误,并提示“无权进行此操作,与登录用户不匹配,请重新登录”。 +* 如果没有提供 `New-Api-User` 请求头,则会返回 `401 Unauthorized` 错误,并提示“无权进行此操作,未提供 New-Api-User”。 +* 如果 `New-Api-User` 请求头格式错误,则会返回 `401 Unauthorized` 错误,并提示“无权进行此操作,New-Api-User 格式错误”。 +* 如果用户已被禁用,则会返回 `403 Forbidden` 错误,并提示“用户已被封禁”。 +* 如果用户权限不足,则会返回 `403 Forbidden` 错误,并提示“无权进行此操作,权限不足”。 +* 如果用户信息无效,则会返回 `403 Forbidden` 错误,并提示“无权进行此操作,用户信息无效”。 + +## Curl 示例 + +假设您的 Access Token 为 `access_token`,用户 ID 为 `123`,要访问的 API 接口为 `/api/user/self`,则可以使用以下 curl 命令: + +```bash +curl -X GET \ + -H "Authorization: access_token" \ + -H "New-Api-User: 123" \ + https://your-domain.com/api/user/self +``` + +请将 `access_token`、`123` 和 `https://your-domain.com` 替换为实际的值。 + diff --git a/docs/api/web_api.md b/docs/api/web_api.md new file mode 100644 index 0000000000000000000000000000000000000000..aa88a606c68f1979d6750ef88876fd38b3196fdf --- /dev/null +++ b/docs/api/web_api.md @@ -0,0 +1,198 @@ +# New API – Web 界面后端接口文档 + +> 本文档汇总了 **New API** 后端提供给前端 Web 界面的全部 REST 接口(不含 *Relay* 相关接口)。 +> +> 接口前缀统一为 `https://`,以下仅列出 **路径**、**HTTP 方法**、**鉴权要求** 与 **功能简介**。 +> +> 鉴权级别说明: +> * **公开** – 不需要登录即可调用 +> * **用户** – 需携带用户 Token(`middleware.UserAuth`) +> * **管理员** – 需管理员 Token(`middleware.AdminAuth`) +> * **Root** – 仅限最高权限 Root 用户(`middleware.RootAuth`) + +--- + +## 1. 初始化 / 系统状态 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/setup | 公开 | 获取系统初始化状态 | +| POST | /api/setup | 公开 | 完成首次安装向导 | +| GET | /api/status | 公开 | 获取运行状态摘要 | +| GET | /api/uptime/status | 公开 | Uptime-Kuma 兼容状态探针 | +| GET | /api/status/test | 管理员 | 测试后端与依赖组件是否正常 | + +## 2. 公共信息 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/models | 用户 | 获取前端可用模型列表 | +| GET | /api/notice | 公开 | 获取公告栏内容 | +| GET | /api/about | 公开 | 关于页面信息 | +| GET | /api/home_page_content | 公开 | 首页自定义内容 | +| GET | /api/pricing | 可匿名/用户 | 价格与套餐信息 | +| GET | /api/ratio_config | 公开 | 模型倍率配置(仅公开字段) | + +## 3. 邮件 / 身份验证 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/verification | 公开 (限流) | 发送邮箱验证邮件 | +| GET | /api/reset_password | 公开 (限流) | 发送重置密码邮件 | +| POST | /api/user/reset | 公开 | 提交重置密码请求 | + +## 4. OAuth / 第三方登录 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/oauth/github | 公开 | GitHub OAuth 跳转 | +| GET | /api/oauth/discord | 公开 | Discord 通用 OAuth 跳转 | +| GET | /api/oauth/oidc | 公开 | OIDC 通用 OAuth 跳转 | +| GET | /api/oauth/linuxdo | 公开 | LinuxDo OAuth 跳转 | +| GET | /api/oauth/wechat | 公开 | 微信扫码登录跳转 | +| GET | /api/oauth/wechat/bind | 公开 | 微信账户绑定 | +| GET | /api/oauth/email/bind | 公开 | 邮箱绑定 | +| GET | /api/oauth/telegram/login | 公开 | Telegram 登录 | +| GET | /api/oauth/telegram/bind | 公开 | Telegram 账户绑定 | +| GET | /api/oauth/state | 公开 | 获取随机 state(防 CSRF) | + +## 5. 用户模块 +### 5.1 账号注册/登录 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| POST | /api/user/register | 公开 | 注册新账号 | +| POST | /api/user/login | 公开 | 用户登录 | +| GET | /api/user/logout | 用户 | 退出登录 | +| GET | /api/user/epay/notify | 公开 | Epay 支付回调 | +| GET | /api/user/groups | 公开 | 列出所有分组(无鉴权版) | + +### 5.2 用户自身操作 (需登录) +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/user/self/groups | 用户 | 获取自己所在分组 | +| GET | /api/user/self | 用户 | 获取个人资料 | +| GET | /api/user/models | 用户 | 获取模型可见性 | +| PUT | /api/user/self | 用户 | 修改个人资料 | +| DELETE | /api/user/self | 用户 | 注销账号 | +| GET | /api/user/token | 用户 | 生成用户级别 Access Token | +| GET | /api/user/aff | 用户 | 获取推广码信息 | +| POST | /api/user/topup | 用户 | 余额直充 | +| POST | /api/user/pay | 用户 | 提交支付订单 | +| POST | /api/user/amount | 用户 | 余额支付 | +| POST | /api/user/aff_transfer | 用户 | 推广额度转账 | +| PUT | /api/user/setting | 用户 | 更新用户设置 | + +### 5.3 管理员用户管理 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/user/ | 管理员 | 获取全部用户列表 | +| GET | /api/user/search | 管理员 | 搜索用户 | +| GET | /api/user/:id | 管理员 | 获取单个用户信息 | +| POST | /api/user/ | 管理员 | 创建用户 | +| POST | /api/user/manage | 管理员 | 冻结/重置等管理操作 | +| PUT | /api/user/ | 管理员 | 更新用户 | +| DELETE | /api/user/:id | 管理员 | 删除用户 | + +## 6. 站点选项 (Root) +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/option/ | Root | 获取全局配置 | +| PUT | /api/option/ | Root | 更新全局配置 | +| POST | /api/option/rest_model_ratio | Root | 重置模型倍率 | +| POST | /api/option/migrate_console_setting | Root | 迁移旧版控制台配置 | + +## 7. 模型倍率同步 (Root) +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/ratio_sync/channels | Root | 获取可同步渠道列表 | +| POST | /api/ratio_sync/fetch | Root | 从上游拉取倍率 | + +## 8. 渠道管理 (管理员) +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/channel/ | 获取渠道列表 | +| GET | /api/channel/search | 搜索渠道 | +| GET | /api/channel/models | 查询渠道模型能力 | +| GET | /api/channel/models_enabled | 查询启用模型能力 | +| GET | /api/channel/:id | 获取单个渠道 | +| GET | /api/channel/test | 批量测试渠道连通性 | +| GET | /api/channel/test/:id | 单个渠道测试 | +| GET | /api/channel/update_balance | 批量刷新余额 | +| GET | /api/channel/update_balance/:id | 单个刷新余额 | +| POST | /api/channel/ | 新增渠道 | +| PUT | /api/channel/ | 更新渠道 | +| DELETE | /api/channel/disabled | 删除已禁用渠道 | +| POST | /api/channel/tag/disabled | 批量禁用标签渠道 | +| POST | /api/channel/tag/enabled | 批量启用标签渠道 | +| PUT | /api/channel/tag | 编辑渠道标签 | +| DELETE | /api/channel/:id | 删除渠道 | +| POST | /api/channel/batch | 批量删除渠道 | +| POST | /api/channel/fix | 修复渠道能力表 | +| GET | /api/channel/fetch_models/:id | 拉取单渠道模型 | +| POST | /api/channel/fetch_models | 拉取全部渠道模型 | +| POST | /api/channel/batch/tag | 批量设置渠道标签 | +| GET | /api/channel/tag/models | 根据标签获取模型 | +| POST | /api/channel/copy/:id | 复制渠道 | + +## 9. Token 管理 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/token/ | 用户 | 获取全部 Token | +| GET | /api/token/search | 用户 | 搜索 Token | +| GET | /api/token/:id | 用户 | 获取单个 Token | +| POST | /api/token/ | 用户 | 创建 Token | +| PUT | /api/token/ | 用户 | 更新 Token | +| DELETE | /api/token/:id | 用户 | 删除 Token | +| POST | /api/token/batch | 用户 | 批量删除 Token | + +## 10. 兑换码管理 (管理员) +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/redemption/ | 获取兑换码列表 | +| GET | /api/redemption/search | 搜索兑换码 | +| GET | /api/redemption/:id | 获取单个兑换码 | +| POST | /api/redemption/ | 创建兑换码 | +| PUT | /api/redemption/ | 更新兑换码 | +| DELETE | /api/redemption/invalid | 删除无效兑换码 | +| DELETE | /api/redemption/:id | 删除兑换码 | + +## 11. 日志 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/log/ | 管理员 | 获取全部日志 | +| DELETE | /api/log/ | 管理员 | 删除历史日志 | +| GET | /api/log/stat | 管理员 | 日志统计 | +| GET | /api/log/self/stat | 用户 | 我的日志统计 | +| GET | /api/log/search | 管理员 | 搜索全部日志 | +| GET | /api/log/self | 用户 | 获取我的日志 | +| GET | /api/log/self/search | 用户 | 搜索我的日志 | +| GET | /api/log/token | 公开 | 根据 Token 查询日志(支持 CORS) | + +## 12. 数据统计 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/data/ | 管理员 | 全站用量按日期统计 | +| GET | /api/data/self | 用户 | 我的用量按日期统计 | + +## 13. 分组 +| GET | /api/group/ | 管理员 | 获取全部分组列表 | + +## 14. Midjourney 任务 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/mj/self | 用户 | 获取自己的 MJ 任务 | +| GET | /api/mj/ | 管理员 | 获取全部 MJ 任务 | + +## 15. 任务中心 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/task/self | 用户 | 获取我的任务 | +| GET | /api/task/ | 管理员 | 获取全部任务 | + +## 16. 账户计费面板 (Dashboard) +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /dashboard/billing/subscription | 用户 Token | 获取订阅额度信息 | +| GET | /v1/dashboard/billing/subscription | 同上 | 兼容 OpenAI SDK 路径 | +| GET | /dashboard/billing/usage | 用户 Token | 获取使用量信息 | +| GET | /v1/dashboard/billing/usage | 同上 | 兼容 OpenAI SDK 路径 | + +--- + +> **更新日期**:2025.07.17 diff --git a/docs/channel/other_setting.md b/docs/channel/other_setting.md new file mode 100644 index 0000000000000000000000000000000000000000..43341660b886b31cdb64b60a52218d0b483d8646 --- /dev/null +++ b/docs/channel/other_setting.md @@ -0,0 +1,33 @@ +# 渠道而外设置说明 + +该配置用于设置一些额外的渠道参数,可以通过 JSON 对象进行配置。主要包含以下两个设置项: + +1. force_format + - 用于标识是否对数据进行强制格式化为 OpenAI 格式 + - 类型为布尔值,设置为 true 时启用强制格式化 + +2. proxy + - 用于配置网络代理 + - 类型为字符串,填写代理地址(例如 socks5 协议的代理地址) + +3. thinking_to_content + - 用于标识是否将思考内容`reasoning_content`转换为``标签拼接到内容中返回 + - 类型为布尔值,设置为 true 时启用思考内容转换 + +-------------------------------------------------------------- + +## JSON 格式示例 + +以下是一个示例配置,启用强制格式化并设置了代理地址: + +```json +{ + "force_format": true, + "thinking_to_content": true, + "proxy": "socks5://xxxxxxx" +} +``` + +-------------------------------------------------------------- + +通过调整上述 JSON 配置中的值,可以灵活控制渠道的额外行为,比如是否进行格式化以及使用特定的网络代理。 diff --git a/docs/images/aliyun.png b/docs/images/aliyun.png new file mode 100644 index 0000000000000000000000000000000000000000..2e3287ce3a081793849649820d9fa16464e48da1 --- /dev/null +++ b/docs/images/aliyun.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc5043de80a786c72bc9d7be5bc6beeafb546c66c14a2bcc407a402886475ad4 +size 5102 diff --git a/docs/images/cherry-studio.png b/docs/images/cherry-studio.png new file mode 100644 index 0000000000000000000000000000000000000000..6c757d1c048877f87bc52fe9ecdbe745add58775 --- /dev/null +++ b/docs/images/cherry-studio.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5436ec4e8e177eb1f19863bb128866307808e00c3dd3432996e76e62db490dd +size 11339 diff --git a/docs/images/io-net.png b/docs/images/io-net.png new file mode 100644 index 0000000000000000000000000000000000000000..4dfbd94bbee0e6b9f0ec7d0928c355a5618409fc --- /dev/null +++ b/docs/images/io-net.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6faa1ffe0ca763b6140aa2e20b01a03ec05905bb1b837c0cb73e266748c2f1c +size 2016 diff --git a/docs/images/pku.png b/docs/images/pku.png new file mode 100644 index 0000000000000000000000000000000000000000..9f1cb62e2af71ddea8ee7d4f0ddaf50c91bcba90 --- /dev/null +++ b/docs/images/pku.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d49b072d4e65c5ddf0fa91eebfbe538d04c401899e35c999f1bae406cdcf6482 +size 12247 diff --git a/docs/images/ucloud.png b/docs/images/ucloud.png new file mode 100644 index 0000000000000000000000000000000000000000..4967bb3c48662e61912a0d6b0af0d753a9f0e922 --- /dev/null +++ b/docs/images/ucloud.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cdd659ac1eac0fc2e8727a4cbdc8b48d8e5e5395e2a0a10fa05b901717da7801 +size 11630 diff --git a/docs/installation/BT.md b/docs/installation/BT.md new file mode 100644 index 0000000000000000000000000000000000000000..b4ea5b2fc04e5ba3746be6eaff12fd83321a92e7 --- /dev/null +++ b/docs/installation/BT.md @@ -0,0 +1,3 @@ +密钥为环境变量SESSION_SECRET + +![8285bba413e770fe9620f1bf9b40d44e](https://github.com/user-attachments/assets/7a6fc03e-c457-45e4-b8f9-184508fc26b0) diff --git a/docs/models/Midjourney.md b/docs/models/Midjourney.md new file mode 100644 index 0000000000000000000000000000000000000000..478115b705e70365c4a5e0c4c85437791a917552 --- /dev/null +++ b/docs/models/Midjourney.md @@ -0,0 +1,82 @@ +# Midjourney Proxy API文档 + +**简介**:Midjourney Proxy API文档 + +## 接口列表 +支持的接口如下: ++ [x] /mj/submit/imagine ++ [x] /mj/submit/change ++ [x] /mj/submit/blend ++ [x] /mj/submit/describe ++ [x] /mj/image/{id} (通过此接口获取图片,**请必须在系统设置中填写服务器地址!!**) ++ [x] /mj/task/{id}/fetch (此接口返回的图片地址为经过One API转发的地址) ++ [x] /task/list-by-condition ++ [x] /mj/submit/action (仅midjourney-proxy-plus支持,下同) ++ [x] /mj/submit/modal ++ [x] /mj/submit/shorten ++ [x] /mj/task/{id}/image-seed ++ [x] /mj/insight-face/swap (InsightFace) + +## 模型列表 + +### midjourney-proxy支持 + +- mj_imagine (绘图) +- mj_variation (变换) +- mj_reroll (重绘) +- mj_blend (混合) +- mj_upscale (放大) +- mj_describe (图生文) + +### 仅midjourney-proxy-plus支持 + +- mj_zoom (比例变焦) +- mj_shorten (提示词缩短) +- mj_modal (窗口提交,局部重绘和自定义比例变焦必须和mj_modal一同添加) +- mj_inpaint (局部重绘提交,必须和mj_modal一同添加) +- mj_custom_zoom (自定义比例变焦,必须和mj_modal一同添加) +- mj_high_variation (强变换) +- mj_low_variation (弱变换) +- mj_pan (平移) +- swap_face (换脸) + +## 模型价格设置(在设置-运营设置-模型固定价格设置中设置) +```json +{ + "mj_imagine": 0.1, + "mj_variation": 0.1, + "mj_reroll": 0.1, + "mj_blend": 0.1, + "mj_modal": 0.1, + "mj_zoom": 0.1, + "mj_shorten": 0.1, + "mj_high_variation": 0.1, + "mj_low_variation": 0.1, + "mj_pan": 0.1, + "mj_inpaint": 0, + "mj_custom_zoom": 0, + "mj_describe": 0.05, + "mj_upscale": 0.05, + "swap_face": 0.05 +} +``` +其中mj_inpaint和mj_custom_zoom的价格设置为0,是因为这两个模型需要搭配mj_modal使用,所以价格由mj_modal决定。 + +## 渠道设置 + +### 对接 midjourney-proxy(plus) + +1. + +部署Midjourney-Proxy,并配置好midjourney账号等(强烈建议设置密钥),[项目地址](https://github.com/novicezk/midjourney-proxy) + +2. 在渠道管理中添加渠道,渠道类型选择**Midjourney Proxy**,如果是plus版本选择**Midjourney Proxy Plus** + ,模型请参考上方模型列表 +3. **代理**填写midjourney-proxy部署的地址,例如:http://localhost:8080 +4. 密钥填写midjourney-proxy的密钥,如果没有设置密钥,可以随便填 + +### 对接上游new api + +1. 在渠道管理中添加渠道,渠道类型选择**Midjourney Proxy Plus**,模型请参考上方模型列表 +2. **代理**填写上游new api的地址,例如:http://localhost:3000 +3. 密钥填写上游new api的密钥 \ No newline at end of file diff --git a/docs/models/Rerank.md b/docs/models/Rerank.md new file mode 100644 index 0000000000000000000000000000000000000000..dc57d99bb0b7020a31eb12f3fa8c5d96012a3ab9 --- /dev/null +++ b/docs/models/Rerank.md @@ -0,0 +1,62 @@ +# Rerank API文档 + +**简介**:Rerank API文档 + +## 接入Dify +模型供应商选择Jina,按要求填写模型信息即可接入Dify。 + +## 请求方式 + +Post: /v1/rerank + +Request: + +```json +{ + "model": "jina-reranker-v2-base-multilingual", + "query": "What is the capital of the United States?", + "top_n": 3, + "documents": [ + "Carson City is the capital city of the American state of Nevada.", + "The Commonwealth of the Northern Mariana Islands is a group of islands in the Pacific Ocean. Its capital is Saipan.", + "Washington, D.C. (also known as simply Washington or D.C., and officially as the District of Columbia) is the capital of the United States. It is a federal district.", + "Capitalization or capitalisation in English grammar is the use of a capital letter at the start of a word. English usage varies from capitalization in other languages.", + "Capital punishment (the death penalty) has existed in the United States since beforethe United States was a country. As of 2017, capital punishment is legal in 30 of the 50 states." + ] +} +``` + +Response: + +```json +{ + "results": [ + { + "document": { + "text": "Washington, D.C. (also known as simply Washington or D.C., and officially as the District of Columbia) is the capital of the United States. It is a federal district." + }, + "index": 2, + "relevance_score": 0.9999702 + }, + { + "document": { + "text": "Carson City is the capital city of the American state of Nevada." + }, + "index": 0, + "relevance_score": 0.67800725 + }, + { + "document": { + "text": "Capitalization or capitalisation in English grammar is the use of a capital letter at the start of a word. English usage varies from capitalization in other languages." + }, + "index": 3, + "relevance_score": 0.02800752 + } + ], + "usage": { + "prompt_tokens": 158, + "completion_tokens": 0, + "total_tokens": 158 + } +} +``` \ No newline at end of file diff --git a/docs/models/Suno.md b/docs/models/Suno.md new file mode 100644 index 0000000000000000000000000000000000000000..840ca8e41e5ab77d2d000ebf22680694218c3203 --- /dev/null +++ b/docs/models/Suno.md @@ -0,0 +1,44 @@ +# Suno API文档 + +**简介**:Suno API文档 + +## 接口列表 +支持的接口如下: ++ [x] /suno/submit/music ++ [x] /suno/submit/lyrics ++ [x] /suno/fetch ++ [x] /suno/fetch/:id + +## 模型列表 + +### Suno API支持 + +- suno_music (自定义模式、灵感模式、续写) +- suno_lyrics (生成歌词) + + +## 模型价格设置(在设置-运营设置-模型固定价格设置中设置) +```json +{ + "suno_music": 0.3, + "suno_lyrics": 0.01 +} +``` + +## 渠道设置 + +### 对接 Suno API + +1. +部署 Suno API,并配置好suno账号等(强烈建议设置密钥),[项目地址](https://github.com/Suno-API/Suno-API) + +2. 在渠道管理中添加渠道,渠道类型选择**Suno API** + ,模型请参考上方模型列表 +3. **代理**填写 Suno API 部署的地址,例如:http://localhost:8080 +4. 密钥填写 Suno API 的密钥,如果没有设置密钥,可以随便填 + +### 对接上游new api + +1. 在渠道管理中添加渠道,渠道类型选择**Suno API**,或任意类型,只需模型包含上方模型列表的模型 +2. **代理**填写上游new api的地址,例如:http://localhost:3000 +3. 密钥填写上游new api的密钥 \ No newline at end of file diff --git a/docs/translation-glossary.fr.md b/docs/translation-glossary.fr.md new file mode 100644 index 0000000000000000000000000000000000000000..d73d0dad4a6817fececb78d8a362b9f3cdec4727 --- /dev/null +++ b/docs/translation-glossary.fr.md @@ -0,0 +1,107 @@ +# Glossaire Français (French Glossary) + +Ce document fournit des traductions standards françaises pour la terminologie clé du projet afin d'assurer la cohérence et la précision des traductions. + +This document provides standard French translations for key project terminology to ensure consistency and accuracy in translations. + +## Concepts de Base (Core Concepts) + +- L'utilisation d'émojis dans les traductions est autorisée s'ils sont présents dans l'original +- L'utilisation de termes purement techniques est autorisée s'ils sont présents dans l'original +- L'utilisation de termes techniques en anglais est autorisée s'ils sont largement utilisés dans l'environnement technique francophone (par exemple, API) + +| Chinois | Français | Anglais | Description | +|---------|----------|---------|-------------| +| 倍率 | Ratio | Ratio/Multiplier | Multiplicateur utilisé pour le calcul des prix. **Important :** Dans le contexte des calculs de prix, toujours utiliser "Ratio" plutôt que "Multiplicateur" pour assurer la cohérence terminologique | +| 令牌 | Jeton | Token | Identifiants d'accès API ou unités de texte traitées par les modèles | +| 渠道 | Canal | Channel | Canal d'accès aux fournisseurs d'API | +| 分组 | Groupe | Group | Classification des utilisateurs ou des jetons | +| 额度 | Quota | Quota | Quota de services disponible pour l'utilisateur | + +## Modèles (Model Related) + +| Chinois | Français | Anglais | Description | +|---------|----------|---------|-------------| +| 提示 | Invite | Prompt | Contenu d'entrée du modèle | +| 补全 | Complétion | Completion | Contenu de sortie du modèle. **Important :** Ne pas utiliser "Achèvement" ou "Finalisation" - uniquement "Complétion" pour correspondre à la terminologie technique | +| 输入 | Entrée | Input/Prompt | Contenu envoyé au modèle | +| 输出 | Sortie | Output/Completion | Contenu retourné par le modèle | +| 模型倍率 | Ratio du modèle | Model Ratio | Ratio de tarification pour différents modèles | +| 补全倍率 | Ratio de complétion | Completion Ratio | Ratio de tarification supplémentaire pour la sortie | +| 固定价格 | Prix fixe | Price per call | Prix par appel | +| 按量计费 | Paiement à l'utilisation | Pay-as-you-go | Tarification basée sur l'utilisation | +| 按次计费 | Paiement par appel | Pay-per-view | Prix fixe par appel | + +## Gestion des Utilisateurs (User Management) + +| Chinois | Français | Anglais | Description | +|---------|----------|---------|-------------| +| 超级管理员 | Super-administrateur | Root User | Administrateur avec les privilèges les plus élevés | +| 管理员 | Administrateur | Admin User | Administrateur système | +| 普通用户 | Utilisateur normal | Normal User | Utilisateur avec privilèges standards | + +## Recharge et Échange (Recharge & Redemption) + +| Chinois | Français | Anglais | Description | +|---------|----------|---------|-------------| +| 充值 | Recharge | Top Up | Ajout de quota au compte | +| 兑换码 | Code d'échange | Redemption Code | Code qui peut être échangé contre du quota | + +## Gestion des Canaux (Channel Management) + +| Chinois | Français | Anglais | Description | +|---------|----------|---------|-------------| +| 渠道 | Canal | Channel | Canal du fournisseur d'API | +| API密钥 | Clé API | API Key | Clé d'accès API. **Important :** Utiliser "Clé API" au lieu de "Jeton API" pour plus de précision et conformément à la terminologie technique francophone établie. Le terme "Clé" reflète mieux la fonctionnalité d'accès aux ressources, tandis que "Jeton" est plus souvent associé aux unités de texte dans le contexte du traitement des modèles linguistiques. | +| 优先级 | Priorité | Priority | Priorité de sélection du canal | +| 权重 | Poids | Weight | Poids d'équilibrage de charge | +| 代理 | Proxy | Proxy | Adresse du serveur proxy | +| 模型重定向 | Redirection de modèle | Model Mapping | Remplacement du nom du modèle dans le corps de la requête | +| 供应商 | Fournisseur | Provider/Vendor | Fournisseur de services ou d'API | + +## Sécurité (Security Related) + +| Chinois | Français | Anglais | Description | +|---------|----------|---------|-------------| +| 两步验证 | Authentification à deux facteurs | Two-Factor Authentication | Méthode de vérification de sécurité supplémentaire pour les comptes | +| 2FA | 2FA | Two-Factor Authentication | Abréviation de l'authentification à deux facteurs | + +## Recommandations de Traduction (Translation Guidelines) + +### Variantes Contextuelles de Traduction + +**Invite/Entrée (Prompt/Input)** + +- **Invite** : Lors de l'interaction avec les LLM, dans l'interface utilisateur, lors de la description de l'interaction avec le modèle +- **Entrée** : Dans la tarification, la documentation technique, la description du processus de traitement des données +- **Règle** : S'il s'agit de l'expérience utilisateur et de l'interaction avec l'IA → "Invite", s'il s'agit du processus technique ou des calculs → "Entrée" + +**Jeton (Token)** + +- Jeton d'accès API (API Token) +- Unité de texte traitée par le modèle (Text Token) +- Jeton d'accès système (Access Token) + +**Quota (Quota)** + +- Quota de services disponible pour l'utilisateur +- Parfois traduit comme "Crédit" + +### Particularités de la Langue Française + +- **Formes plurielles** : Nécessite une implémentation correcte des formes plurielles (_one, _other) +- **Accords grammaticaux** : Attention aux accords grammaticaux dans les termes techniques +- **Genre grammatical** : Accord du genre des termes techniques (par exemple, "modèle" - masculin, "canal" - masculin) + +### Termes Standardisés + +- **Complétion (Completion)** : Contenu de sortie du modèle +- **Ratio (Ratio)** : Multiplicateur pour le calcul des prix +- **Code d'échange (Redemption Code)** : Utilisé au lieu de "Code d'échange" pour plus de précision +- **Fournisseur (Provider/Vendor)** : Organisation ou service fournissant des API ou des modèles d'IA + +--- + +**Note pour les contributeurs :** Si vous trouvez des incohérences dans les traductions de terminologie ou si vous avez de meilleures suggestions de traduction pour le français, n'hésitez pas à créer une Issue ou une Pull Request. + +**Contribution Note for French:** If you find any inconsistencies in terminology translations or have better translation suggestions for French, please feel free to submit an Issue or Pull Request. \ No newline at end of file diff --git a/docs/translation-glossary.md b/docs/translation-glossary.md new file mode 100644 index 0000000000000000000000000000000000000000..c5f68ad15178ed7694257821916dfc900f5cc258 --- /dev/null +++ b/docs/translation-glossary.md @@ -0,0 +1,86 @@ +# 翻译术语表 (Translation Glossary) + +本文档为翻译贡献者提供项目中关键术语的标准翻译参考,以确保翻译的一致性和准确性。 + +This document provides standard translation references for key terminology in the project to ensure consistency and accuracy for translation contributors. + +## 核心概念 (Core Concepts) + +| 中文 | English | 说明 | Description | +|------|---------|------|-------------| +| 倍率 | Ratio | 用于计算价格的乘数因子 | Multiplier factor used for price calculation | +| 令牌 | Token | API访问凭证,也指模型处理的文本单元 | API access credentials or text units processed by models | +| 渠道 | Channel | API服务提供商的接入通道 | Access channel for API service providers | +| 分组 | Group | 用户或令牌的分类,影响价格倍率 | Classification of users or tokens, affecting price ratios | +| 额度 | Quota | 用户可用的服务额度 | Available service quota for users | + +## 模型相关 (Model Related) + +| 中文 | English | 说明 | Description | +|------|---------|------|-------------| +| 提示 | Prompt | 模型输入内容 | Model input content | +| 补全 | Completion | 模型输出内容 | Model output content | +| 输入 | Input/Prompt | 发送给模型的内容 | Content sent to the model | +| 输出 | Output/Completion | 模型返回的内容 | Content returned by the model | +| 模型倍率 | Model Ratio | 不同模型的计费倍率 | Billing ratio for different models | +| 补全倍率 | Completion Ratio | 输出内容的额外计费倍率 | Additional billing ratio for output content | +| 固定价格 | Price per call | 按次计费的价格 | Fixed price per call | +| 按量计费 | Pay-as-you-go | 根据使用量计费 | Billing based on usage | +| 按次计费 | Pay-per-view | 每次调用固定价格 | Fixed price per invocation | + +## 用户管理 (User Management) + +| 中文 | English | 说明 | Description | +|------|---------|------|-------------| +| 超级管理员 | Root User | 最高权限管理员 | Administrator with highest privileges | +| 管理员 | Admin User | 系统管理员 | System administrator | +| 普通用户 | Normal User | 普通权限用户 | Regular user with standard privileges | + +## 充值与兑换 (Recharge & Redemption) + +| 中文 | English | 说明 | Description | +|------|---------|------|-------------| +| 充值 | Top Up | 为账户增加额度 | Add quota to account | +| 兑换码 | Redemption Code | 可兑换额度的代码 | Code that can be redeemed for quota | + +## 渠道管理 (Channel Management) + +| 中文 | English | 说明 | Description | +|------|---------|------|-------------| +| 渠道 | Channel | API服务提供通道 | API service provider channel | +| 密钥 | Key | API访问密钥 | API access key | +| 优先级 | Priority | 渠道选择优先级 | Channel selection priority | +| 权重 | Weight | 负载均衡权重 | Load balancing weight | +| 代理 | Proxy | 代理服务器地址 | Proxy server address | +| 模型重定向 | Model Mapping | 请求体中模型名称替换 | Model name replacement in request body | + +## 安全相关 (Security Related) + +| 中文 | English | 说明 | Description | +|------|---------|------|-------------| +| 两步验证 | Two-Factor Authentication | 为账户提供额外安全保护的验证方式 | Additional security verification method for accounts | +| 2FA | Two-Factor Authentication | 两步验证的缩写 | Abbreviation for Two-Factor Authentication | + +## 计费相关 (Billing Related) + +| 中文 | English | 说明 | Description | +|------|---------|------|-------------| +| 倍率 | Ratio | 价格计算的乘数因子 | Multiplier factor used for price calculation | +| 倍率 | Multiplier | 价格计算的乘数因子(同义词) | Multiplier factor used for price calculation (synonym) | + +## 翻译注意事项 (Translation Guidelines) + +- **提示 (Prompt)** = 模型输入内容 / Model input content +- **补全 (Completion)** = 模型输出内容 / Model output content +- **倍率 (Ratio)** = 价格计算的乘数因子 / Multiplier factor for price calculation +- **额度 (Quota)** = 可用的用户服务额度,有时也翻译为 Credit / Available service quota for users, sometimes also translated as Credit +- **Token** = 根据上下文可能指 / Depending on context, may refer to: + - API访问令牌 (API Token) + - 模型处理的文本单元 (Text Token) + - 系统访问令牌 (Access Token) + +--- + +**贡献说明**: 如发现术语翻译不一致或有更好的翻译建议,欢迎提交 Issue 或 Pull Request。 + +**Contribution Note**: If you find any inconsistencies in terminology translations or have better translation suggestions, please feel free to submit an Issue or Pull Request. diff --git a/docs/translation-glossary.ru.md b/docs/translation-glossary.ru.md new file mode 100644 index 0000000000000000000000000000000000000000..60a9bd280311f9d97adb2de275239d8ffc92f909 --- /dev/null +++ b/docs/translation-glossary.ru.md @@ -0,0 +1,107 @@ +# Русский глоссарий (Russian Glossary) + +Данный раздел предоставляет стандартные переводы ключевой терминологии проекта на русский язык для обеспечения согласованности и точности переводов. + +This section provides standard Russian translations for key project terminology to ensure consistency and accuracy in translations. + +## Основные концепции (Core Concepts) + +- Допускается использовать символы Emoji в переводе, если они были в оригинале. +- Допускается использование сугубо технических терминов, если они были в оригинале. +- Допускается использование технических терминов на английском языке, если они широко используются в русскоязычной технической среде (например, API). + +| Китайский | Русский | Английский | Описание | +|-----------|--------|-----------|----------| +| 倍率 | Коэффициент | Ratio/Multiplier | Множитель для расчета цены. **Важно:** В контексте расчетов цен всегда использовать "Коэффициент", а не "Множитель" для обеспечения консистентности терминологии | +| 令牌 | Токен | Token | Учетные данные API или текстовые единицы | +| 渠道 | Канал | Channel | Канал доступа к поставщику API | +| 分组 | Группа | Group | Классификация пользователей или токенов | +| 额度 | Квота | Quota | Доступная квота услуг для пользователя | + +## Модели (Model Related) + +| Китайский | Русский | Английский | Описание | +|-----------|--------|-----------|----------| +| 提示 | Промпт/Ввод | Prompt | Содержимое ввода в модель | +| 补全 | Вывод | Completion | Содержимое вывода модели. **Важно:** Не использовать "Дополнение" или "Завершение" - только "Вывод" для соответствия технической терминологии | +| 输入 | Ввод | Input/Prompt | Содержимое, отправляемое в модель | +| 输出 | Вывод | Output/Completion | Содержимое, возвращаемое моделью | +| 模型倍率 | Коэффициент модели | Model Ratio | Коэффициент тарификации для разных моделей | +| 补全倍率 | Коэффициент вывода | Completion Ratio | Дополнительный коэффициент тарификации для вывода | +| 固定价格 | Цена за запрос | Price per call | Цена за один вызов | +| 按量计费 | Оплата по объему | Pay-as-you-go | Тарификация на основе использования | +| 按次计费 | Оплата за запрос | Pay-per-view | Фиксированная цена за вызов | + +## Управление пользователями (User Management) + +| Китайский | Русский | Английский | Описание | +|-----------|--------|-----------|----------| +| 超级管理员 | Суперадминистратор | Root User | Администратор с наивысшими привилегиями | +| 管理员 | Администратор | Admin User | Системный администратор | +| 普通用户 | Обычный пользователь | Normal User | Пользователь со стандартными привилегиями | + +## Пополнение и обмен (Recharge & Redemption) + +| Китайский | Русский | Английский | Описание | +|-----------|--------|-----------|----------| +| 充值 | Пополнение | Top Up | Добавление квоты на аккаунт | +| 兑换码 | Код купона | Redemption Code | Код, который можно обменять на квоту | + +## Управление каналами (Channel Management) + +| Китайский | Русский | Английский | Описание | +|-----------|--------|-----------|----------| +| 渠道 | Канал | Channel | Канал поставщика API | +| API密钥 | API ключ | API Key | Ключ доступа к API. **Важно:** Использовать "API ключ" вместо "API токен" для большей точности и соответствия общепринятой русскоязычной технической терминологии. Термин "ключ" более точно отражает функционал доступа к ресурсам, в то время как "токен" чаще ассоциируется с текстовыми единицами в контексте обработки языковых моделей. | +| 优先级 | Приоритет | Priority | Приоритет выбора канала | +| 权重 | Вес | Weight | Вес балансировки нагрузки | +| 代理 | Прокси | Proxy | Адрес прокси-сервера | +| 模型重定向 | Перенаправление модели | Model Mapping | Замена имени модели в теле запроса | +| 供应商 | Поставщик | Provider/Vendor | Поставщик услуг или API | + +## Безопасность (Security Related) + +| Китайский | Русский | Английский | Описание | +|-----------|--------|-----------|----------| +| 两步验证 | Двухфакторная аутентификация | Two-Factor Authentication | Дополнительный метод проверки безопасности для аккаунтов | +| 2FA | 2FA | Two-Factor Authentication | Аббревиатура двухфакторной аутентификации | + +## Рекомендации по переводу (Translation Guidelines) + +### Контекстуальные варианты перевода + +**Промпт/Ввод (Prompt/Input)** + +- **Промпт**: При общении с LLM, в пользовательском интерфейсе, при описании взаимодействия с моделью +- **Ввод**: При тарификации, технической документации, описании процесса обработки данных +- **Правило**: Если речь о пользовательском опыте и взаимодействии с AI → "Промпт", если о техническом процессе или расчетах → "Ввод" + +**Token** + +- API токен доступа (API Token) +- Текстовая единица, обрабатываемая моделью (Text Token) +- Токен доступа к системе (Access Token) + +**Квота (Quota)** + +- Доступная квота услуг пользователя +- Иногда переводится как "Кредит" + +### Особенности русского языка + +- **Множественные формы**: Требуется правильная реализация множественных форм (_one,_few, _many,_other) +- **Падежные окончания**: Внимательное отношение к падежным окончаниям в технических терминах +- **Грамматический род**: Согласование рода технических терминов (например, "модель" - женский род, "канал" - мужской род) + +### Стандартизированные термины + +- **Вывод (Completion)**: Содержимое вывода модели +- **Коэффициент (Ratio)**: Множитель для расчета цены +- **Код купона (Redemption Code)**: Используется вместо "Код обмена" для большей точности +- **Поставщик (Provider/Vendor)**: Организация или сервис, предоставляющий API или AI-модели + +--- + +**Примечание для участников:** При обнаружении несогласованности в переводах терминологии или наличии лучших предложений по переводу, не стесняйтесь создавать Issue или Pull Request. + +**Contribution Note for Russian:** If you find any inconsistencies in terminology translations or have better translation suggestions for Russian, please feel free to submit an Issue or Pull Request. diff --git a/dto/audio.go b/dto/audio.go new file mode 100644 index 0000000000000000000000000000000000000000..ea51516f84410aef9132db48ad8b680b8c2aed94 --- /dev/null +++ b/dto/audio.go @@ -0,0 +1,63 @@ +package dto + +import ( + "encoding/json" + + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type AudioRequest struct { + Model string `json:"model"` + Input string `json:"input"` + Voice string `json:"voice"` + Instructions string `json:"instructions,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` + Speed float64 `json:"speed,omitempty"` + StreamFormat string `json:"stream_format,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta { + meta := &types.TokenCountMeta{ + CombineText: r.Input, + TokenType: types.TokenTypeTextNumber, + } + return meta +} + +func (r *AudioRequest) IsStream(c *gin.Context) bool { + return false +} + +func (r *AudioRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} + +type AudioResponse struct { + Text string `json:"text"` +} + +type WhisperVerboseJSONResponse struct { + Task string `json:"task,omitempty"` + Language string `json:"language,omitempty"` + Duration float64 `json:"duration,omitempty"` + Text string `json:"text,omitempty"` + Segments []Segment `json:"segments,omitempty"` +} + +type Segment struct { + Id int `json:"id"` + Seek int `json:"seek"` + Start float64 `json:"start"` + End float64 `json:"end"` + Text string `json:"text"` + Tokens []int `json:"tokens"` + Temperature float64 `json:"temperature"` + AvgLogprob float64 `json:"avg_logprob"` + CompressionRatio float64 `json:"compression_ratio"` + NoSpeechProb float64 `json:"no_speech_prob"` +} diff --git a/dto/channel_settings.go b/dto/channel_settings.go new file mode 100644 index 0000000000000000000000000000000000000000..e88f2235e330e0410512be7d587ae93d4c401d56 --- /dev/null +++ b/dto/channel_settings.go @@ -0,0 +1,41 @@ +package dto + +type ChannelSettings struct { + ForceFormat bool `json:"force_format,omitempty"` + ThinkingToContent bool `json:"thinking_to_content,omitempty"` + Proxy string `json:"proxy"` + PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` + SystemPromptOverride bool `json:"system_prompt_override,omitempty"` +} + +type VertexKeyType string + +const ( + VertexKeyTypeJSON VertexKeyType = "json" + VertexKeyTypeAPIKey VertexKeyType = "api_key" +) + +type AwsKeyType string + +const ( + AwsKeyTypeAKSK AwsKeyType = "ak_sk" // 默认 + AwsKeyTypeApiKey AwsKeyType = "api_key" +) + +type ChannelOtherSettings struct { + AzureResponsesVersion string `json:"azure_responses_version,omitempty"` + VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" + OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"` + AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费) + DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) + AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私) + AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"` +} + +func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool { + if s == nil || s.OpenRouterEnterprise == nil { + return false + } + return *s.OpenRouterEnterprise +} diff --git a/dto/claude.go b/dto/claude.go new file mode 100644 index 0000000000000000000000000000000000000000..3646fa8159c68a14ba11c57e7032ac39c7bc2e89 --- /dev/null +++ b/dto/claude.go @@ -0,0 +1,555 @@ +package dto + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type ClaudeMetadata struct { + UserId string `json:"user_id"` +} + +type ClaudeMediaMessage struct { + Type string `json:"type,omitempty"` + Text *string `json:"text,omitempty"` + Model string `json:"model,omitempty"` + Source *ClaudeMessageSource `json:"source,omitempty"` + Usage *ClaudeUsage `json:"usage,omitempty"` + StopReason *string `json:"stop_reason,omitempty"` + PartialJson *string `json:"partial_json,omitempty"` + Role string `json:"role,omitempty"` + Thinking *string `json:"thinking,omitempty"` + Signature string `json:"signature,omitempty"` + Delta string `json:"delta,omitempty"` + CacheControl json.RawMessage `json:"cache_control,omitempty"` + // tool_calls + Id string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input any `json:"input,omitempty"` + Content any `json:"content,omitempty"` + ToolUseId string `json:"tool_use_id,omitempty"` +} + +func (c *ClaudeMediaMessage) SetText(s string) { + c.Text = &s +} + +func (c *ClaudeMediaMessage) GetText() string { + if c.Text == nil { + return "" + } + return *c.Text +} + +func (c *ClaudeMediaMessage) IsStringContent() bool { + if c.Content == nil { + return false + } + _, ok := c.Content.(string) + if ok { + return true + } + return false +} + +func (c *ClaudeMediaMessage) GetStringContent() string { + if c.Content == nil { + return "" + } + switch c.Content.(type) { + case string: + return c.Content.(string) + case []any: + var contentStr string + for _, contentItem := range c.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + + return "" +} + +func (c *ClaudeMediaMessage) GetJsonRowString() string { + jsonContent, _ := common.Marshal(c) + return string(jsonContent) +} + +func (c *ClaudeMediaMessage) SetContent(content any) { + c.Content = content +} + +func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage { + mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.Content) + return mediaContent +} + +type ClaudeMessageSource struct { + Type string `json:"type"` + MediaType string `json:"media_type,omitempty"` + Data any `json:"data,omitempty"` + Url string `json:"url,omitempty"` +} + +type ClaudeMessage struct { + Role string `json:"role"` + Content any `json:"content"` +} + +func (c *ClaudeMessage) IsStringContent() bool { + if c.Content == nil { + return false + } + _, ok := c.Content.(string) + return ok +} + +func (c *ClaudeMessage) GetStringContent() string { + if c.Content == nil { + return "" + } + switch c.Content.(type) { + case string: + return c.Content.(string) + case []any: + var contentStr string + for _, contentItem := range c.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + + return "" +} + +func (c *ClaudeMessage) SetStringContent(content string) { + c.Content = content +} + +func (c *ClaudeMessage) SetContent(content any) { + c.Content = content +} + +func (c *ClaudeMessage) ParseContent() ([]ClaudeMediaMessage, error) { + return common.Any2Type[[]ClaudeMediaMessage](c.Content) +} + +type Tool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema map[string]interface{} `json:"input_schema"` +} + +type InputSchema struct { + Type string `json:"type"` + Properties any `json:"properties,omitempty"` + Required any `json:"required,omitempty"` +} + +type ClaudeWebSearchTool struct { + Type string `json:"type"` + Name string `json:"name"` + MaxUses int `json:"max_uses,omitempty"` + UserLocation *ClaudeWebSearchUserLocation `json:"user_location,omitempty"` +} + +type ClaudeWebSearchUserLocation struct { + Type string `json:"type"` + Timezone string `json:"timezone,omitempty"` + Country string `json:"country,omitempty"` + Region string `json:"region,omitempty"` + City string `json:"city,omitempty"` +} + +type ClaudeToolChoice struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"` +} + +type ClaudeRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt,omitempty"` + System any `json:"system,omitempty"` + Messages []ClaudeMessage `json:"messages,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + MaxTokensToSample uint `json:"max_tokens_to_sample,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools any `json:"tools,omitempty"` + ContextManagement json.RawMessage `json:"context_management,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Thinking *Thinking `json:"thinking,omitempty"` + McpServers json.RawMessage `json:"mcp_servers,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + // 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤 + ServiceTier string `json:"service_tier,omitempty"` +} + +func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta { + var tokenCountMeta = types.TokenCountMeta{ + TokenType: types.TokenTypeTokenizer, + MaxTokens: int(c.MaxTokens), + } + + var texts = make([]string, 0) + var fileMeta = make([]*types.FileMeta, 0) + + // system + if c.System != nil { + if c.IsStringSystem() { + sys := c.GetStringSystem() + if sys != "" { + texts = append(texts, sys) + } + } else { + systemMedia := c.ParseSystem() + for _, media := range systemMedia { + switch media.Type { + case "text": + texts = append(texts, media.GetText()) + case "image": + if media.Source != nil { + data := media.Source.Url + if data == "" { + data = common.Interface2String(media.Source.Data) + } + if data != "" { + fileMeta = append(fileMeta, &types.FileMeta{FileType: types.FileTypeImage, OriginData: data}) + } + } + } + } + } + } + + // messages + for _, message := range c.Messages { + tokenCountMeta.MessagesCount++ + texts = append(texts, message.Role) + if message.IsStringContent() { + content := message.GetStringContent() + if content != "" { + texts = append(texts, content) + } + continue + } + + content, _ := message.ParseContent() + for _, media := range content { + switch media.Type { + case "text": + texts = append(texts, media.GetText()) + case "image": + if media.Source != nil { + data := media.Source.Url + if data == "" { + data = common.Interface2String(media.Source.Data) + } + if data != "" { + fileMeta = append(fileMeta, &types.FileMeta{FileType: types.FileTypeImage, OriginData: data}) + } + } + case "tool_use": + if media.Name != "" { + texts = append(texts, media.Name) + } + if media.Input != nil { + b, _ := common.Marshal(media.Input) + texts = append(texts, string(b)) + } + case "tool_result": + if media.Content != nil { + b, _ := common.Marshal(media.Content) + texts = append(texts, string(b)) + } + } + } + } + + // tools + if c.Tools != nil { + tools := c.GetTools() + normalTools, webSearchTools := ProcessTools(tools) + if normalTools != nil { + for _, t := range normalTools { + tokenCountMeta.ToolsCount++ + if t.Name != "" { + texts = append(texts, t.Name) + } + if t.Description != "" { + texts = append(texts, t.Description) + } + if t.InputSchema != nil { + b, _ := common.Marshal(t.InputSchema) + texts = append(texts, string(b)) + } + } + } + if webSearchTools != nil { + for _, t := range webSearchTools { + tokenCountMeta.ToolsCount++ + if t.Name != "" { + texts = append(texts, t.Name) + } + if t.UserLocation != nil { + b, _ := common.Marshal(t.UserLocation) + texts = append(texts, string(b)) + } + } + } + } + + tokenCountMeta.CombineText = strings.Join(texts, "\n") + tokenCountMeta.Files = fileMeta + return &tokenCountMeta +} + +func (c *ClaudeRequest) IsStream(ctx *gin.Context) bool { + return c.Stream +} + +func (c *ClaudeRequest) SetModelName(modelName string) { + if modelName != "" { + c.Model = modelName + } +} + +func (c *ClaudeRequest) SearchToolNameByToolCallId(toolCallId string) string { + for _, message := range c.Messages { + content, _ := message.ParseContent() + for _, mediaMessage := range content { + if mediaMessage.Id == toolCallId { + return mediaMessage.Name + } + } + } + return "" +} + +// AddTool 添加工具到请求中 +func (c *ClaudeRequest) AddTool(tool any) { + if c.Tools == nil { + c.Tools = make([]any, 0) + } + + switch tools := c.Tools.(type) { + case []any: + c.Tools = append(tools, tool) + default: + // 如果Tools不是[]any类型,重新初始化为[]any + c.Tools = []any{tool} + } +} + +// GetTools 获取工具列表 +func (c *ClaudeRequest) GetTools() []any { + if c.Tools == nil { + return nil + } + + switch tools := c.Tools.(type) { + case []any: + return tools + default: + return nil + } +} + +// ProcessTools 处理工具列表,支持类型断言 +func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) { + var normalTools []*Tool + var webSearchTools []*ClaudeWebSearchTool + + for _, tool := range tools { + switch t := tool.(type) { + case *Tool: + normalTools = append(normalTools, t) + case *ClaudeWebSearchTool: + webSearchTools = append(webSearchTools, t) + case Tool: + normalTools = append(normalTools, &t) + case ClaudeWebSearchTool: + webSearchTools = append(webSearchTools, &t) + default: + // 未知类型,跳过 + continue + } + } + + return normalTools, webSearchTools +} + +type Thinking struct { + Type string `json:"type"` + BudgetTokens *int `json:"budget_tokens,omitempty"` +} + +func (c *Thinking) GetBudgetTokens() int { + if c.BudgetTokens == nil { + return 0 + } + return *c.BudgetTokens +} + +func (c *ClaudeRequest) IsStringSystem() bool { + _, ok := c.System.(string) + return ok +} + +func (c *ClaudeRequest) GetStringSystem() string { + if c.IsStringSystem() { + return c.System.(string) + } + return "" +} + +func (c *ClaudeRequest) SetStringSystem(system string) { + c.System = system +} + +func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage { + mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.System) + return mediaContent +} + +type ClaudeErrorWithStatusCode struct { + Error types.ClaudeError `json:"error"` + StatusCode int `json:"status_code"` + LocalError bool +} + +type ClaudeResponse struct { + Id string `json:"id,omitempty"` + Type string `json:"type"` + Role string `json:"role,omitempty"` + Content []ClaudeMediaMessage `json:"content,omitempty"` + Completion string `json:"completion,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + Model string `json:"model,omitempty"` + Error any `json:"error,omitempty"` + Usage *ClaudeUsage `json:"usage,omitempty"` + Index *int `json:"index,omitempty"` + ContentBlock *ClaudeMediaMessage `json:"content_block,omitempty"` + Delta *ClaudeMediaMessage `json:"delta,omitempty"` + Message *ClaudeMediaMessage `json:"message,omitempty"` +} + +// set index +func (c *ClaudeResponse) SetIndex(i int) { + c.Index = &i +} + +// get index +func (c *ClaudeResponse) GetIndex() int { + if c.Index == nil { + return 0 + } + return *c.Index +} + +// GetClaudeError 从动态错误类型中提取ClaudeError结构 +func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError { + if c.Error == nil { + return nil + } + + switch err := c.Error.(type) { + case types.ClaudeError: + return &err + case *types.ClaudeError: + return err + case map[string]interface{}: + // 处理从JSON解析来的map结构 + claudeErr := &types.ClaudeError{} + if errType, ok := err["type"].(string); ok { + claudeErr.Type = errType + } + if errMsg, ok := err["message"].(string); ok { + claudeErr.Message = errMsg + } + return claudeErr + case string: + // 处理简单字符串错误 + return &types.ClaudeError{ + Type: "upstream_error", + Message: err, + } + default: + // 未知类型,尝试转换为字符串 + return &types.ClaudeError{ + Type: "unknown_upstream_error", + Message: fmt.Sprintf("unknown_error: %v", err), + } + } +} + +type ClaudeUsage struct { + InputTokens int `json:"input_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreation *ClaudeCacheCreationUsage `json:"cache_creation,omitempty"` + // claude cache 1h + ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"` + ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"` + ServerToolUse *ClaudeServerToolUse `json:"server_tool_use,omitempty"` +} + +type ClaudeCacheCreationUsage struct { + Ephemeral5mInputTokens int `json:"ephemeral_5m_input_tokens,omitempty"` + Ephemeral1hInputTokens int `json:"ephemeral_1h_input_tokens,omitempty"` +} + +func (u *ClaudeUsage) GetCacheCreation5mTokens() int { + if u == nil || u.CacheCreation == nil { + return 0 + } + return u.CacheCreation.Ephemeral5mInputTokens +} + +func (u *ClaudeUsage) GetCacheCreation1hTokens() int { + if u == nil || u.CacheCreation == nil { + return 0 + } + return u.CacheCreation.Ephemeral1hInputTokens +} + +func (u *ClaudeUsage) GetCacheCreationTotalTokens() int { + if u == nil { + return 0 + } + if u.CacheCreationInputTokens > 0 { + return u.CacheCreationInputTokens + } + return u.GetCacheCreation5mTokens() + u.GetCacheCreation1hTokens() +} + +type ClaudeServerToolUse struct { + WebSearchRequests int `json:"web_search_requests"` +} diff --git a/dto/embedding.go b/dto/embedding.go new file mode 100644 index 0000000000000000000000000000000000000000..d0730f9f59cf478221149485e3d5987449cb6dd8 --- /dev/null +++ b/dto/embedding.go @@ -0,0 +1,88 @@ +package dto + +import ( + "strings" + + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type EmbeddingOptions struct { + Seed int `json:"seed,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopK int `json:"top_k,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + NumPredict int `json:"num_predict,omitempty"` + NumCtx int `json:"num_ctx,omitempty"` +} + +type EmbeddingRequest struct { + Model string `json:"model"` + Input any `json:"input"` + EncodingFormat string `json:"encoding_format,omitempty"` + Dimensions int `json:"dimensions,omitempty"` + User string `json:"user,omitempty"` + Seed float64 `json:"seed,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` + PresencePenalty float64 `json:"presence_penalty,omitempty"` +} + +func (r *EmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta { + var texts = make([]string, 0) + + inputs := r.ParseInput() + for _, input := range inputs { + texts = append(texts, input) + } + + return &types.TokenCountMeta{ + CombineText: strings.Join(texts, "\n"), + } +} + +func (r *EmbeddingRequest) IsStream(c *gin.Context) bool { + return false +} + +func (r *EmbeddingRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} + +func (r *EmbeddingRequest) ParseInput() []string { + if r.Input == nil { + return make([]string, 0) + } + var input []string + switch r.Input.(type) { + case string: + input = []string{r.Input.(string)} + case []any: + input = make([]string, 0, len(r.Input.([]any))) + for _, item := range r.Input.([]any) { + if str, ok := item.(string); ok { + input = append(input, str) + } + } + } + return input +} + +type EmbeddingResponseItem struct { + Object string `json:"object"` + Index int `json:"index"` + Embedding []float64 `json:"embedding"` +} + +type EmbeddingResponse struct { + Object string `json:"object"` + Data []EmbeddingResponseItem `json:"data"` + Model string `json:"model"` + Usage `json:"usage"` +} diff --git a/dto/error.go b/dto/error.go new file mode 100644 index 0000000000000000000000000000000000000000..79547671bf8b36df57b9e5e68952617818ccf8c8 --- /dev/null +++ b/dto/error.go @@ -0,0 +1,57 @@ +package dto + +import "github.com/QuantumNous/new-api/types" + +type OpenAIError struct { + Message string `json:"message"` + Type string `json:"type"` + Param string `json:"param"` + Code any `json:"code"` +} + +type OpenAIErrorWithStatusCode struct { + Error OpenAIError `json:"error"` + StatusCode int `json:"status_code"` + LocalError bool +} + +type GeneralErrorResponse struct { + Error types.OpenAIError `json:"error"` + Message string `json:"message"` + Msg string `json:"msg"` + Err string `json:"err"` + ErrorMsg string `json:"error_msg"` + Header struct { + Message string `json:"message"` + } `json:"header"` + Response struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } `json:"response"` +} + +func (e GeneralErrorResponse) ToMessage() string { + if e.Error.Message != "" { + return e.Error.Message + } + if e.Message != "" { + return e.Message + } + if e.Msg != "" { + return e.Msg + } + if e.Err != "" { + return e.Err + } + if e.ErrorMsg != "" { + return e.ErrorMsg + } + if e.Header.Message != "" { + return e.Header.Message + } + if e.Response.Error.Message != "" { + return e.Response.Error.Message + } + return "" +} diff --git a/dto/gemini.go b/dto/gemini.go new file mode 100644 index 0000000000000000000000000000000000000000..fa869b6d1c33ca616111e97712e225bded491c37 --- /dev/null +++ b/dto/gemini.go @@ -0,0 +1,463 @@ +package dto + +import ( + "encoding/json" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type GeminiChatRequest struct { + Requests []GeminiChatRequest `json:"requests,omitempty"` // For batch requests + Contents []GeminiChatContent `json:"contents"` + SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"` + GenerationConfig GeminiChatGenerationConfig `json:"generationConfig,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` + ToolConfig *ToolConfig `json:"toolConfig,omitempty"` + SystemInstructions *GeminiChatContent `json:"systemInstruction,omitempty"` + CachedContent string `json:"cachedContent,omitempty"` +} + +type ToolConfig struct { + FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"` + RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"` +} + +type FunctionCallingConfig struct { + Mode FunctionCallingConfigMode `json:"mode,omitempty"` + AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"` +} +type FunctionCallingConfigMode string + +type RetrievalConfig struct { + LatLng *LatLng `json:"latLng,omitempty"` + LanguageCode string `json:"languageCode,omitempty"` +} + +type LatLng struct { + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` +} + +func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta { + var files []*types.FileMeta = make([]*types.FileMeta, 0) + + var maxTokens int + + if r.GenerationConfig.MaxOutputTokens > 0 { + maxTokens = int(r.GenerationConfig.MaxOutputTokens) + } + + var inputTexts []string + for _, content := range r.Contents { + for _, part := range content.Parts { + if part.Text != "" { + inputTexts = append(inputTexts, part.Text) + } + if part.InlineData != nil && part.InlineData.Data != "" { + if strings.HasPrefix(part.InlineData.MimeType, "image/") { + files = append(files, &types.FileMeta{ + FileType: types.FileTypeImage, + OriginData: part.InlineData.Data, + }) + } else if strings.HasPrefix(part.InlineData.MimeType, "audio/") { + files = append(files, &types.FileMeta{ + FileType: types.FileTypeAudio, + OriginData: part.InlineData.Data, + }) + } else if strings.HasPrefix(part.InlineData.MimeType, "video/") { + files = append(files, &types.FileMeta{ + FileType: types.FileTypeVideo, + OriginData: part.InlineData.Data, + }) + } else { + files = append(files, &types.FileMeta{ + FileType: types.FileTypeFile, + OriginData: part.InlineData.Data, + }) + } + } + } + } + + inputText := strings.Join(inputTexts, "\n") + return &types.TokenCountMeta{ + CombineText: inputText, + Files: files, + MaxTokens: maxTokens, + } +} + +func (r *GeminiChatRequest) IsStream(c *gin.Context) bool { + if c.Query("alt") == "sse" { + return true + } + return false +} + +func (r *GeminiChatRequest) SetModelName(modelName string) { + // GeminiChatRequest does not have a model field, so this method does nothing. +} + +func (r *GeminiChatRequest) GetTools() []GeminiChatTool { + var tools []GeminiChatTool + if strings.HasSuffix(string(r.Tools), "[") { + // is array + if err := common.Unmarshal(r.Tools, &tools); err != nil { + logger.LogError(nil, "error_unmarshalling_tools: "+err.Error()) + return nil + } + } else if strings.HasPrefix(string(r.Tools), "{") { + // is object + singleTool := GeminiChatTool{} + if err := common.Unmarshal(r.Tools, &singleTool); err != nil { + logger.LogError(nil, "error_unmarshalling_single_tool: "+err.Error()) + return nil + } + tools = []GeminiChatTool{singleTool} + } + return tools +} + +func (r *GeminiChatRequest) SetTools(tools []GeminiChatTool) { + if len(tools) == 0 { + r.Tools = json.RawMessage("[]") + return + } + + // Marshal the tools to JSON + data, err := common.Marshal(tools) + if err != nil { + logger.LogError(nil, "error_marshalling_tools: "+err.Error()) + return + } + r.Tools = data +} + +type GeminiThinkingConfig struct { + IncludeThoughts bool `json:"includeThoughts,omitempty"` + ThinkingBudget *int `json:"thinkingBudget,omitempty"` + // TODO Conflict with thinkingbudget. + ThinkingLevel json.RawMessage `json:"thinkingLevel,omitempty"` +} + +// UnmarshalJSON allows GeminiThinkingConfig to accept both snake_case and camelCase fields. +func (c *GeminiThinkingConfig) UnmarshalJSON(data []byte) error { + type Alias GeminiThinkingConfig + var aux struct { + Alias + IncludeThoughtsSnake *bool `json:"include_thoughts,omitempty"` + ThinkingBudgetSnake *int `json:"thinking_budget,omitempty"` + ThinkingLevelSnake json.RawMessage `json:"thinking_level,omitempty"` + } + + if err := common.Unmarshal(data, &aux); err != nil { + return err + } + + *c = GeminiThinkingConfig(aux.Alias) + + if aux.IncludeThoughtsSnake != nil { + c.IncludeThoughts = *aux.IncludeThoughtsSnake + } + + if aux.ThinkingBudgetSnake != nil { + c.ThinkingBudget = aux.ThinkingBudgetSnake + } + + if len(aux.ThinkingLevelSnake) > 0 { + c.ThinkingLevel = aux.ThinkingLevelSnake + } + + return nil +} + +func (c *GeminiThinkingConfig) SetThinkingBudget(budget int) { + c.ThinkingBudget = &budget +} + +type GeminiInlineData struct { + MimeType string `json:"mimeType"` + Data string `json:"data"` +} + +// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType +func (g *GeminiInlineData) UnmarshalJSON(data []byte) error { + type Alias GeminiInlineData // Use type alias to avoid recursion + var aux struct { + Alias + MimeTypeSnake string `json:"mime_type"` + } + + if err := common.Unmarshal(data, &aux); err != nil { + return err + } + + *g = GeminiInlineData(aux.Alias) // Copy other fields if any in future + + // Prioritize snake_case if present + if aux.MimeTypeSnake != "" { + g.MimeType = aux.MimeTypeSnake + } else if aux.MimeType != "" { // Fallback to camelCase from Alias + g.MimeType = aux.MimeType + } + // g.Data would be populated by aux.Alias.Data + return nil +} + +type FunctionCall struct { + FunctionName string `json:"name"` + Arguments any `json:"args"` +} + +type GeminiFunctionResponse struct { + Name string `json:"name"` + Response map[string]interface{} `json:"response"` + WillContinue json.RawMessage `json:"willContinue,omitempty"` + Scheduling json.RawMessage `json:"scheduling,omitempty"` + Parts json.RawMessage `json:"parts,omitempty"` + ID json.RawMessage `json:"id,omitempty"` +} + +type GeminiPartExecutableCode struct { + Language string `json:"language,omitempty"` + Code string `json:"code,omitempty"` +} + +type GeminiPartCodeExecutionResult struct { + Outcome string `json:"outcome,omitempty"` + Output string `json:"output,omitempty"` +} + +type GeminiFileData struct { + MimeType string `json:"mimeType,omitempty"` + FileUri string `json:"fileUri,omitempty"` +} + +type GeminiPart struct { + Text string `json:"text,omitempty"` + Thought bool `json:"thought,omitempty"` + InlineData *GeminiInlineData `json:"inlineData,omitempty"` + FunctionCall *FunctionCall `json:"functionCall,omitempty"` + ThoughtSignature json.RawMessage `json:"thoughtSignature,omitempty"` + FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"` + // Optional. Media resolution for the input media. + MediaResolution json.RawMessage `json:"mediaResolution,omitempty"` + VideoMetadata json.RawMessage `json:"videoMetadata,omitempty"` + FileData *GeminiFileData `json:"fileData,omitempty"` + ExecutableCode *GeminiPartExecutableCode `json:"executableCode,omitempty"` + CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"` +} + +// UnmarshalJSON custom unmarshaler for GeminiPart to support snake_case and camelCase for InlineData +func (p *GeminiPart) UnmarshalJSON(data []byte) error { + // Alias to avoid recursion during unmarshalling + type Alias GeminiPart + var aux struct { + Alias + InlineDataSnake *GeminiInlineData `json:"inline_data,omitempty"` // snake_case variant + } + + if err := common.Unmarshal(data, &aux); err != nil { + return err + } + + // Assign fields from alias + *p = GeminiPart(aux.Alias) + + // Prioritize snake_case for InlineData if present + if aux.InlineDataSnake != nil { + p.InlineData = aux.InlineDataSnake + } else if aux.InlineData != nil { // Fallback to camelCase from Alias + p.InlineData = aux.InlineData + } + // Other fields like Text, FunctionCall etc. are already populated via aux.Alias + + return nil +} + +type GeminiChatContent struct { + Role string `json:"role,omitempty"` + Parts []GeminiPart `json:"parts"` +} + +type GeminiChatSafetySettings struct { + Category string `json:"category"` + Threshold string `json:"threshold"` +} + +type GeminiChatTool struct { + GoogleSearch any `json:"googleSearch,omitempty"` + GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"` + CodeExecution any `json:"codeExecution,omitempty"` + FunctionDeclarations any `json:"functionDeclarations,omitempty"` + URLContext any `json:"urlContext,omitempty"` +} + +type GeminiChatGenerationConfig struct { + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"topP,omitempty"` + TopK float64 `json:"topK,omitempty"` + MaxOutputTokens uint `json:"maxOutputTokens,omitempty"` + CandidateCount int `json:"candidateCount,omitempty"` + StopSequences []string `json:"stopSequences,omitempty"` + ResponseMimeType string `json:"responseMimeType,omitempty"` + ResponseSchema any `json:"responseSchema,omitempty"` + ResponseJsonSchema json.RawMessage `json:"responseJsonSchema,omitempty"` + PresencePenalty *float32 `json:"presencePenalty,omitempty"` + FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty"` + ResponseLogprobs bool `json:"responseLogprobs,omitempty"` + Logprobs *int32 `json:"logprobs,omitempty"` + MediaResolution MediaResolution `json:"mediaResolution,omitempty"` + Seed int64 `json:"seed,omitempty"` + ResponseModalities []string `json:"responseModalities,omitempty"` + ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"` + SpeechConfig json.RawMessage `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config + ImageConfig json.RawMessage `json:"imageConfig,omitempty"` // RawMessage to allow flexible image config +} + +type MediaResolution string + +type GeminiChatCandidate struct { + Content GeminiChatContent `json:"content"` + FinishReason *string `json:"finishReason"` + Index int64 `json:"index"` + SafetyRatings []GeminiChatSafetyRating `json:"safetyRatings"` +} + +type GeminiChatSafetyRating struct { + Category string `json:"category"` + Probability string `json:"probability"` +} + +type GeminiChatPromptFeedback struct { + SafetyRatings []GeminiChatSafetyRating `json:"safetyRatings"` + BlockReason *string `json:"blockReason,omitempty"` +} + +type GeminiChatResponse struct { + Candidates []GeminiChatCandidate `json:"candidates"` + PromptFeedback *GeminiChatPromptFeedback `json:"promptFeedback,omitempty"` + UsageMetadata GeminiUsageMetadata `json:"usageMetadata"` +} + +type GeminiUsageMetadata struct { + PromptTokenCount int `json:"promptTokenCount"` + CandidatesTokenCount int `json:"candidatesTokenCount"` + TotalTokenCount int `json:"totalTokenCount"` + ThoughtsTokenCount int `json:"thoughtsTokenCount"` + PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"` +} + +type GeminiPromptTokensDetails struct { + Modality string `json:"modality"` + TokenCount int `json:"tokenCount"` +} + +// Imagen related structs +type GeminiImageRequest struct { + Instances []GeminiImageInstance `json:"instances"` + Parameters GeminiImageParameters `json:"parameters"` +} + +type GeminiImageInstance struct { + Prompt string `json:"prompt"` +} + +type GeminiImageParameters struct { + SampleCount int `json:"sampleCount,omitempty"` + AspectRatio string `json:"aspectRatio,omitempty"` + PersonGeneration string `json:"personGeneration,omitempty"` + ImageSize string `json:"imageSize,omitempty"` +} + +type GeminiImageResponse struct { + Predictions []GeminiImagePrediction `json:"predictions"` +} + +type GeminiImagePrediction struct { + MimeType string `json:"mimeType"` + BytesBase64Encoded string `json:"bytesBase64Encoded"` + RaiFilteredReason string `json:"raiFilteredReason,omitempty"` + SafetyAttributes any `json:"safetyAttributes,omitempty"` +} + +// Embedding related structs +type GeminiEmbeddingRequest struct { + Model string `json:"model,omitempty"` + Content GeminiChatContent `json:"content"` + TaskType string `json:"taskType,omitempty"` + Title string `json:"title,omitempty"` + OutputDimensionality int `json:"outputDimensionality,omitempty"` +} + +func (r *GeminiEmbeddingRequest) IsStream(c *gin.Context) bool { + // Gemini embedding requests are not streamed + return false +} + +func (r *GeminiEmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta { + var inputTexts []string + for _, part := range r.Content.Parts { + if part.Text != "" { + inputTexts = append(inputTexts, part.Text) + } + } + inputText := strings.Join(inputTexts, "\n") + return &types.TokenCountMeta{ + CombineText: inputText, + } +} + +func (r *GeminiEmbeddingRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} + +type GeminiBatchEmbeddingRequest struct { + Requests []*GeminiEmbeddingRequest `json:"requests"` +} + +func (r *GeminiBatchEmbeddingRequest) IsStream(c *gin.Context) bool { + // Gemini batch embedding requests are not streamed + return false +} + +func (r *GeminiBatchEmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta { + var inputTexts []string + for _, request := range r.Requests { + meta := request.GetTokenCountMeta() + if meta != nil && meta.CombineText != "" { + inputTexts = append(inputTexts, meta.CombineText) + } + } + inputText := strings.Join(inputTexts, "\n") + return &types.TokenCountMeta{ + CombineText: inputText, + } +} + +func (r *GeminiBatchEmbeddingRequest) SetModelName(modelName string) { + if modelName != "" { + for _, req := range r.Requests { + req.SetModelName(modelName) + } + } +} + +type GeminiEmbeddingResponse struct { + Embedding ContentEmbedding `json:"embedding"` +} + +type GeminiBatchEmbeddingResponse struct { + Embeddings []*ContentEmbedding `json:"embeddings"` +} + +type ContentEmbedding struct { + Values []float64 `json:"values"` +} diff --git a/dto/midjourney.go b/dto/midjourney.go new file mode 100644 index 0000000000000000000000000000000000000000..6fbcb3574f1f55d64a78f8b1763d2b53fe3377a2 --- /dev/null +++ b/dto/midjourney.go @@ -0,0 +1,107 @@ +package dto + +//type SimpleMjRequest struct { +// Prompt string `json:"prompt"` +// CustomId string `json:"customId"` +// Action string `json:"action"` +// Content string `json:"content"` +//} + +type SwapFaceRequest struct { + SourceBase64 string `json:"sourceBase64"` + TargetBase64 string `json:"targetBase64"` +} + +type MidjourneyRequest struct { + Prompt string `json:"prompt"` + CustomId string `json:"customId"` + BotType string `json:"botType"` + NotifyHook string `json:"notifyHook"` + Action string `json:"action"` + Index int `json:"index"` + State string `json:"state"` + TaskId string `json:"taskId"` + Base64Array []string `json:"base64Array"` + Content string `json:"content"` + MaskBase64 string `json:"maskBase64"` +} + +type MidjourneyResponse struct { + Code int `json:"code"` + Description string `json:"description"` + Properties interface{} `json:"properties"` + Result string `json:"result"` +} + +type MidjourneyUploadResponse struct { + Code int `json:"code"` + Description string `json:"description"` + Result []string `json:"result"` +} + +type MidjourneyResponseWithStatusCode struct { + StatusCode int `json:"statusCode"` + Response MidjourneyResponse +} + +type MidjourneyDto struct { + MjId string `json:"id"` + Action string `json:"action"` + CustomId string `json:"customId"` + BotType string `json:"botType"` + Prompt string `json:"prompt"` + PromptEn string `json:"promptEn"` + Description string `json:"description"` + State string `json:"state"` + SubmitTime int64 `json:"submitTime"` + StartTime int64 `json:"startTime"` + FinishTime int64 `json:"finishTime"` + ImageUrl string `json:"imageUrl"` + VideoUrl string `json:"videoUrl"` + VideoUrls []ImgUrls `json:"videoUrls"` + Status string `json:"status"` + Progress string `json:"progress"` + FailReason string `json:"failReason"` + Buttons any `json:"buttons"` + MaskBase64 string `json:"maskBase64"` + Properties *Properties `json:"properties"` +} + +type ImgUrls struct { + Url string `json:"url"` +} + +type MidjourneyStatus struct { + Status int `json:"status"` +} +type MidjourneyWithoutStatus struct { + Id int `json:"id"` + Code int `json:"code"` + UserId int `json:"user_id" gorm:"index"` + Action string `json:"action"` + MjId string `json:"mj_id" gorm:"index"` + Prompt string `json:"prompt"` + PromptEn string `json:"prompt_en"` + Description string `json:"description"` + State string `json:"state"` + SubmitTime int64 `json:"submit_time"` + StartTime int64 `json:"start_time"` + FinishTime int64 `json:"finish_time"` + ImageUrl string `json:"image_url"` + Progress string `json:"progress"` + FailReason string `json:"fail_reason"` + ChannelId int `json:"channel_id"` +} + +type ActionButton struct { + CustomId any `json:"customId"` + Emoji any `json:"emoji"` + Label any `json:"label"` + Type any `json:"type"` + Style any `json:"style"` +} + +type Properties struct { + FinalPrompt string `json:"finalPrompt"` + FinalZhPrompt string `json:"finalZhPrompt"` +} diff --git a/dto/notify.go b/dto/notify.go new file mode 100644 index 0000000000000000000000000000000000000000..b75cec70cae493900ddbd31271fc5059efc1003f --- /dev/null +++ b/dto/notify.go @@ -0,0 +1,25 @@ +package dto + +type Notify struct { + Type string `json:"type"` + Title string `json:"title"` + Content string `json:"content"` + Values []interface{} `json:"values"` +} + +const ContentValueParam = "{{value}}" + +const ( + NotifyTypeQuotaExceed = "quota_exceed" + NotifyTypeChannelUpdate = "channel_update" + NotifyTypeChannelTest = "channel_test" +) + +func NewNotify(t string, title string, content string, values []interface{}) Notify { + return Notify{ + Type: t, + Title: title, + Content: content, + Values: values, + } +} diff --git a/dto/openai_image.go b/dto/openai_image.go new file mode 100644 index 0000000000000000000000000000000000000000..927c37bc7b60dacfb080fc7a5d695175f85bb6f5 --- /dev/null +++ b/dto/openai_image.go @@ -0,0 +1,175 @@ +package dto + +import ( + "encoding/json" + "reflect" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type ImageRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt" binding:"required"` + N uint `json:"n,omitempty"` + Size string `json:"size,omitempty"` + Quality string `json:"quality,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` + Style json.RawMessage `json:"style,omitempty"` + User json.RawMessage `json:"user,omitempty"` + ExtraFields json.RawMessage `json:"extra_fields,omitempty"` + Background json.RawMessage `json:"background,omitempty"` + Moderation json.RawMessage `json:"moderation,omitempty"` + OutputFormat json.RawMessage `json:"output_format,omitempty"` + OutputCompression json.RawMessage `json:"output_compression,omitempty"` + PartialImages json.RawMessage `json:"partial_images,omitempty"` + // Stream bool `json:"stream,omitempty"` + Watermark *bool `json:"watermark,omitempty"` + Image json.RawMessage `json:"image,omitempty"` + // 用匿名参数接收额外参数 + Extra map[string]json.RawMessage `json:"-"` +} + +func (i *ImageRequest) UnmarshalJSON(data []byte) error { + // 先解析成 map[string]interface{} + var rawMap map[string]json.RawMessage + if err := common.Unmarshal(data, &rawMap); err != nil { + return err + } + + // 用 struct tag 获取所有已定义字段名 + knownFields := GetJSONFieldNames(reflect.TypeOf(*i)) + + // 再正常解析已定义字段 + type Alias ImageRequest + var known Alias + if err := common.Unmarshal(data, &known); err != nil { + return err + } + *i = ImageRequest(known) + + // 提取多余字段 + i.Extra = make(map[string]json.RawMessage) + for k, v := range rawMap { + if _, ok := knownFields[k]; !ok { + i.Extra[k] = v + } + } + return nil +} + +// 序列化时需要重新把字段平铺 +func (r ImageRequest) MarshalJSON() ([]byte, error) { + // 将已定义字段转为 map + type Alias ImageRequest + alias := Alias(r) + base, err := common.Marshal(alias) + if err != nil { + return nil, err + } + + var baseMap map[string]json.RawMessage + if err := common.Unmarshal(base, &baseMap); err != nil { + return nil, err + } + + // 不能合并ExtraFields!!!!!!!! + // 合并 ExtraFields + //for k, v := range r.Extra { + // if _, exists := baseMap[k]; !exists { + // baseMap[k] = v + // } + //} + + return common.Marshal(baseMap) +} + +func GetJSONFieldNames(t reflect.Type) map[string]struct{} { + fields := make(map[string]struct{}) + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + // 跳过匿名字段(例如 ExtraFields) + if field.Anonymous { + continue + } + + tag := field.Tag.Get("json") + if tag == "-" || tag == "" { + continue + } + + // 取逗号前字段名(排除 omitempty 等) + name := tag + if commaIdx := indexComma(tag); commaIdx != -1 { + name = tag[:commaIdx] + } + fields[name] = struct{}{} + } + return fields +} + +func indexComma(s string) int { + for i := 0; i < len(s); i++ { + if s[i] == ',' { + return i + } + } + return -1 +} + +func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta { + var sizeRatio = 1.0 + var qualityRatio = 1.0 + + if strings.HasPrefix(i.Model, "dall-e") { + // Size + if i.Size == "256x256" { + sizeRatio = 0.4 + } else if i.Size == "512x512" { + sizeRatio = 0.45 + } else if i.Size == "1024x1024" { + sizeRatio = 1 + } else if i.Size == "1024x1792" || i.Size == "1792x1024" { + sizeRatio = 2 + } + + if i.Model == "dall-e-3" && i.Quality == "hd" { + qualityRatio = 2.0 + if i.Size == "1024x1792" || i.Size == "1792x1024" { + qualityRatio = 1.5 + } + } + } + + // not support token count for dalle + return &types.TokenCountMeta{ + CombineText: i.Prompt, + MaxTokens: 1584, + ImagePriceRatio: sizeRatio * qualityRatio * float64(i.N), + } +} + +func (i *ImageRequest) IsStream(c *gin.Context) bool { + return false +} + +func (i *ImageRequest) SetModelName(modelName string) { + if modelName != "" { + i.Model = modelName + } +} + +type ImageResponse struct { + Data []ImageData `json:"data"` + Created int64 `json:"created"` + Extra any `json:"extra,omitempty"` +} +type ImageData struct { + Url string `json:"url,omitempty"` + B64Json string `json:"b64_json,omitempty"` + RevisedPrompt string `json:"revised_prompt,omitempty"` +} diff --git a/dto/openai_request.go b/dto/openai_request.go new file mode 100644 index 0000000000000000000000000000000000000000..fccb44af085523bc345267096dfa3f80a49e06c9 --- /dev/null +++ b/dto/openai_request.go @@ -0,0 +1,1004 @@ +package dto + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type ResponseFormat struct { + Type string `json:"type,omitempty"` + JsonSchema json.RawMessage `json:"json_schema,omitempty"` +} + +type FormatJsonSchema struct { + Description string `json:"description,omitempty"` + Name string `json:"name"` + Schema any `json:"schema,omitempty"` + Strict json.RawMessage `json:"strict,omitempty"` +} + +type GeneralOpenAIRequest struct { + Model string `json:"model,omitempty"` + Messages []Message `json:"messages,omitempty"` + Prompt any `json:"prompt,omitempty"` + Prefix any `json:"prefix,omitempty"` + Suffix any `json:"suffix,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + Verbosity json.RawMessage `json:"verbosity,omitempty"` // gpt-5 + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + Stop any `json:"stop,omitempty"` + N int `json:"n,omitempty"` + Input any `json:"input,omitempty"` + Instruction string `json:"instruction,omitempty"` + Size string `json:"size,omitempty"` + Functions json.RawMessage `json:"functions,omitempty"` + FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` + PresencePenalty float64 `json:"presence_penalty,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + EncodingFormat json.RawMessage `json:"encoding_format,omitempty"` + Seed float64 `json:"seed,omitempty"` + ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"` + Tools []ToolCallRequest `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + User string `json:"user,omitempty"` + LogProbs bool `json:"logprobs,omitempty"` + TopLogProbs int `json:"top_logprobs,omitempty"` + Dimensions int `json:"dimensions,omitempty"` + Modalities json.RawMessage `json:"modalities,omitempty"` + Audio json.RawMessage `json:"audio,omitempty"` + // 安全标识符,用于帮助 OpenAI 检测可能违反使用政策的应用程序用户 + // 注意:此字段会向 OpenAI 发送用户标识信息,默认过滤以保护用户隐私 + SafetyIdentifier string `json:"safety_identifier,omitempty"` + // Whether or not to store the output of this chat completion request for use in our model distillation or evals products. + // 是否存储此次请求数据供 OpenAI 用于评估和优化产品 + // 注意:默认过滤此字段以保护用户隐私,但过滤后可能导致 Codex 无法正常使用 + Store json.RawMessage `json:"store,omitempty"` + // Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"` + LogitBias json.RawMessage `json:"logit_bias,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + Prediction json.RawMessage `json:"prediction,omitempty"` + // gemini + ExtraBody json.RawMessage `json:"extra_body,omitempty"` + //xai + SearchParameters json.RawMessage `json:"search_parameters,omitempty"` + // claude + WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` + // OpenRouter Params + Usage json.RawMessage `json:"usage,omitempty"` + Reasoning json.RawMessage `json:"reasoning,omitempty"` + // Ali Qwen Params + VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"` + EnableThinking any `json:"enable_thinking,omitempty"` + // ollama Params + Think json.RawMessage `json:"think,omitempty"` + // baidu v2 + WebSearch json.RawMessage `json:"web_search,omitempty"` + // doubao,zhipu_v4 + THINKING json.RawMessage `json:"thinking,omitempty"` + // pplx Params + SearchDomainFilter json.RawMessage `json:"search_domain_filter,omitempty"` + SearchRecencyFilter string `json:"search_recency_filter,omitempty"` + ReturnImages bool `json:"return_images,omitempty"` + ReturnRelatedQuestions bool `json:"return_related_questions,omitempty"` + SearchMode string `json:"search_mode,omitempty"` +} + +func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta { + var tokenCountMeta types.TokenCountMeta + var texts = make([]string, 0) + var fileMeta = make([]*types.FileMeta, 0) + + if r.Prompt != nil { + switch v := r.Prompt.(type) { + case string: + texts = append(texts, v) + case []any: + for _, item := range v { + if str, ok := item.(string); ok { + texts = append(texts, str) + } + } + default: + texts = append(texts, fmt.Sprintf("%v", r.Prompt)) + } + } + + if r.Input != nil { + inputs := r.ParseInput() + texts = append(texts, inputs...) + } + + if r.MaxCompletionTokens > r.MaxTokens { + tokenCountMeta.MaxTokens = int(r.MaxCompletionTokens) + } else { + tokenCountMeta.MaxTokens = int(r.MaxTokens) + } + + for _, message := range r.Messages { + tokenCountMeta.MessagesCount++ + texts = append(texts, message.Role) + if message.Content != nil { + if message.Name != nil { + tokenCountMeta.NameCount++ + texts = append(texts, *message.Name) + } + arrayContent := message.ParseContent() + for _, m := range arrayContent { + if m.Type == ContentTypeImageURL { + imageUrl := m.GetImageMedia() + if imageUrl != nil { + if imageUrl.Url != "" { + meta := &types.FileMeta{ + FileType: types.FileTypeImage, + } + meta.OriginData = imageUrl.Url + meta.Detail = imageUrl.Detail + fileMeta = append(fileMeta, meta) + } + } + } else if m.Type == ContentTypeInputAudio { + inputAudio := m.GetInputAudio() + if inputAudio != nil { + meta := &types.FileMeta{ + FileType: types.FileTypeAudio, + } + meta.OriginData = inputAudio.Data + fileMeta = append(fileMeta, meta) + } + } else if m.Type == ContentTypeFile { + file := m.GetFile() + if file != nil { + meta := &types.FileMeta{ + FileType: types.FileTypeFile, + } + meta.OriginData = file.FileData + fileMeta = append(fileMeta, meta) + } + } else if m.Type == ContentTypeVideoUrl { + videoUrl := m.GetVideoUrl() + if videoUrl != nil && videoUrl.Url != "" { + meta := &types.FileMeta{ + FileType: types.FileTypeVideo, + } + meta.OriginData = videoUrl.Url + fileMeta = append(fileMeta, meta) + } + } else { + texts = append(texts, m.Text) + } + } + } + } + + if r.Tools != nil { + openaiTools := r.Tools + for _, tool := range openaiTools { + tokenCountMeta.ToolsCount++ + texts = append(texts, tool.Function.Name) + if tool.Function.Description != "" { + texts = append(texts, tool.Function.Description) + } + if tool.Function.Parameters != nil { + texts = append(texts, fmt.Sprintf("%v", tool.Function.Parameters)) + } + } + //toolTokens := CountTokenInput(countStr, request.Model) + //tkm += 8 + //tkm += toolTokens + } + tokenCountMeta.CombineText = strings.Join(texts, "\n") + tokenCountMeta.Files = fileMeta + return &tokenCountMeta +} + +func (r *GeneralOpenAIRequest) IsStream(c *gin.Context) bool { + return r.Stream +} + +func (r *GeneralOpenAIRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} + +func (r *GeneralOpenAIRequest) ToMap() map[string]any { + result := make(map[string]any) + data, _ := common.Marshal(r) + _ = common.Unmarshal(data, &result) + return result +} + +func (r *GeneralOpenAIRequest) GetSystemRoleName() string { + if strings.HasPrefix(r.Model, "o") { + if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") { + return "developer" + } + } else if strings.HasPrefix(r.Model, "gpt-5") { + return "developer" + } + return "system" +} + +const CustomType = "custom" + +type ToolCallRequest struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Function FunctionRequest `json:"function,omitempty"` + Custom json.RawMessage `json:"custom,omitempty"` +} + +type FunctionRequest struct { + Description string `json:"description,omitempty"` + Name string `json:"name"` + Parameters any `json:"parameters,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +type StreamOptions struct { + IncludeUsage bool `json:"include_usage,omitempty"` +} + +func (r *GeneralOpenAIRequest) GetMaxTokens() uint { + if r.MaxCompletionTokens != 0 { + return r.MaxCompletionTokens + } + return r.MaxTokens +} + +func (r *GeneralOpenAIRequest) ParseInput() []string { + if r.Input == nil { + return nil + } + var input []string + switch r.Input.(type) { + case string: + input = []string{r.Input.(string)} + case []any: + input = make([]string, 0, len(r.Input.([]any))) + for _, item := range r.Input.([]any) { + if str, ok := item.(string); ok { + input = append(input, str) + } + } + } + return input +} + +type Message struct { + Role string `json:"role"` + Content any `json:"content"` + Name *string `json:"name,omitempty"` + Prefix *bool `json:"prefix,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + Reasoning string `json:"reasoning,omitempty"` + ToolCalls json.RawMessage `json:"tool_calls,omitempty"` + ToolCallId string `json:"tool_call_id,omitempty"` + parsedContent []MediaContent + //parsedStringContent *string +} + +type MediaContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageUrl any `json:"image_url,omitempty"` + InputAudio any `json:"input_audio,omitempty"` + File any `json:"file,omitempty"` + VideoUrl any `json:"video_url,omitempty"` + // OpenRouter Params + CacheControl json.RawMessage `json:"cache_control,omitempty"` +} + +func (m *MediaContent) GetImageMedia() *MessageImageUrl { + if m.ImageUrl != nil { + if _, ok := m.ImageUrl.(*MessageImageUrl); ok { + return m.ImageUrl.(*MessageImageUrl) + } + if itemMap, ok := m.ImageUrl.(map[string]any); ok { + out := &MessageImageUrl{ + Url: common.Interface2String(itemMap["url"]), + Detail: common.Interface2String(itemMap["detail"]), + MimeType: common.Interface2String(itemMap["mime_type"]), + } + return out + } + } + return nil +} + +func (m *MediaContent) GetInputAudio() *MessageInputAudio { + if m.InputAudio != nil { + if _, ok := m.InputAudio.(*MessageInputAudio); ok { + return m.InputAudio.(*MessageInputAudio) + } + if itemMap, ok := m.InputAudio.(map[string]any); ok { + out := &MessageInputAudio{ + Data: common.Interface2String(itemMap["data"]), + Format: common.Interface2String(itemMap["format"]), + } + return out + } + } + return nil +} + +func (m *MediaContent) GetFile() *MessageFile { + if m.File != nil { + if _, ok := m.File.(*MessageFile); ok { + return m.File.(*MessageFile) + } + if itemMap, ok := m.File.(map[string]any); ok { + out := &MessageFile{ + FileName: common.Interface2String(itemMap["file_name"]), + FileData: common.Interface2String(itemMap["file_data"]), + FileId: common.Interface2String(itemMap["file_id"]), + } + return out + } + } + return nil +} + +func (m *MediaContent) GetVideoUrl() *MessageVideoUrl { + if m.VideoUrl != nil { + if _, ok := m.VideoUrl.(*MessageVideoUrl); ok { + return m.VideoUrl.(*MessageVideoUrl) + } + if itemMap, ok := m.VideoUrl.(map[string]any); ok { + out := &MessageVideoUrl{ + Url: common.Interface2String(itemMap["url"]), + } + return out + } + } + return nil +} + +type MessageImageUrl struct { + Url string `json:"url"` + Detail string `json:"detail"` + MimeType string +} + +func (m *MessageImageUrl) IsRemoteImage() bool { + return strings.HasPrefix(m.Url, "http") +} + +type MessageInputAudio struct { + Data string `json:"data"` //base64 + Format string `json:"format"` +} + +type MessageFile struct { + FileName string `json:"filename,omitempty"` + FileData string `json:"file_data,omitempty"` + FileId string `json:"file_id,omitempty"` +} + +type MessageVideoUrl struct { + Url string `json:"url"` +} + +const ( + ContentTypeText = "text" + ContentTypeImageURL = "image_url" + ContentTypeInputAudio = "input_audio" + ContentTypeFile = "file" + ContentTypeVideoUrl = "video_url" // 阿里百炼视频识别 + //ContentTypeAudioUrl = "audio_url" +) + +func (m *Message) GetPrefix() bool { + if m.Prefix == nil { + return false + } + return *m.Prefix +} + +func (m *Message) SetPrefix(prefix bool) { + m.Prefix = &prefix +} + +func (m *Message) ParseToolCalls() []ToolCallRequest { + if m.ToolCalls == nil { + return nil + } + var toolCalls []ToolCallRequest + if err := json.Unmarshal(m.ToolCalls, &toolCalls); err == nil { + return toolCalls + } + return toolCalls +} + +func (m *Message) SetToolCalls(toolCalls any) { + toolCallsJson, _ := json.Marshal(toolCalls) + m.ToolCalls = toolCallsJson +} + +func (m *Message) StringContent() string { + switch m.Content.(type) { + case string: + return m.Content.(string) + case []any: + var contentStr string + for _, contentItem := range m.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + + return "" +} + +func (m *Message) SetNullContent() { + m.Content = nil + m.parsedContent = nil +} + +func (m *Message) SetStringContent(content string) { + m.Content = content + m.parsedContent = nil +} + +func (m *Message) SetMediaContent(content []MediaContent) { + m.Content = content + m.parsedContent = content +} + +func (m *Message) IsStringContent() bool { + _, ok := m.Content.(string) + if ok { + return true + } + return false +} + +func (m *Message) ParseContent() []MediaContent { + if m.Content == nil { + return nil + } + if len(m.parsedContent) > 0 { + return m.parsedContent + } + + var contentList []MediaContent + // 先尝试解析为字符串 + content, ok := m.Content.(string) + if ok { + contentList = []MediaContent{{ + Type: ContentTypeText, + Text: content, + }} + m.parsedContent = contentList + return contentList + } + + // 尝试解析为数组 + //var arrayContent []map[string]interface{} + + arrayContent, ok := m.Content.([]any) + if !ok { + return contentList + } + + for _, contentItemAny := range arrayContent { + mediaItem, ok := contentItemAny.(MediaContent) + if ok { + contentList = append(contentList, mediaItem) + continue + } + + contentItem, ok := contentItemAny.(map[string]any) + if !ok { + continue + } + contentType, ok := contentItem["type"].(string) + if !ok { + continue + } + + switch contentType { + case ContentTypeText: + if text, ok := contentItem["text"].(string); ok { + contentList = append(contentList, MediaContent{ + Type: ContentTypeText, + Text: text, + }) + } + + case ContentTypeImageURL: + imageUrl := contentItem["image_url"] + temp := &MessageImageUrl{ + Detail: "high", + } + switch v := imageUrl.(type) { + case string: + temp.Url = v + case map[string]interface{}: + url, ok1 := v["url"].(string) + detail, ok2 := v["detail"].(string) + if ok2 { + temp.Detail = detail + } + if ok1 { + temp.Url = url + } + } + contentList = append(contentList, MediaContent{ + Type: ContentTypeImageURL, + ImageUrl: temp, + }) + + case ContentTypeInputAudio: + if audioData, ok := contentItem["input_audio"].(map[string]interface{}); ok { + data, ok1 := audioData["data"].(string) + format, ok2 := audioData["format"].(string) + if ok1 && ok2 { + temp := &MessageInputAudio{ + Data: data, + Format: format, + } + contentList = append(contentList, MediaContent{ + Type: ContentTypeInputAudio, + InputAudio: temp, + }) + } + } + case ContentTypeFile: + if fileData, ok := contentItem["file"].(map[string]interface{}); ok { + fileId, ok3 := fileData["file_id"].(string) + if ok3 { + contentList = append(contentList, MediaContent{ + Type: ContentTypeFile, + File: &MessageFile{ + FileId: fileId, + }, + }) + } else { + fileName, ok1 := fileData["filename"].(string) + fileDataStr, ok2 := fileData["file_data"].(string) + if ok1 && ok2 { + contentList = append(contentList, MediaContent{ + Type: ContentTypeFile, + File: &MessageFile{ + FileName: fileName, + FileData: fileDataStr, + }, + }) + } + } + } + case ContentTypeVideoUrl: + if videoUrl, ok := contentItem["video_url"].(string); ok { + contentList = append(contentList, MediaContent{ + Type: ContentTypeVideoUrl, + VideoUrl: &MessageVideoUrl{ + Url: videoUrl, + }, + }) + } + } + } + + if len(contentList) > 0 { + m.parsedContent = contentList + } + return contentList +} + +// old code +/*func (m *Message) StringContent() string { + if m.parsedStringContent != nil { + return *m.parsedStringContent + } + + var stringContent string + if err := json.Unmarshal(m.Content, &stringContent); err == nil { + m.parsedStringContent = &stringContent + return stringContent + } + + contentStr := new(strings.Builder) + arrayContent := m.ParseContent() + for _, content := range arrayContent { + if content.Type == ContentTypeText { + contentStr.WriteString(content.Text) + } + } + stringContent = contentStr.String() + m.parsedStringContent = &stringContent + + return stringContent +} + +func (m *Message) SetNullContent() { + m.Content = nil + m.parsedStringContent = nil + m.parsedContent = nil +} + +func (m *Message) SetStringContent(content string) { + jsonContent, _ := json.Marshal(content) + m.Content = jsonContent + m.parsedStringContent = &content + m.parsedContent = nil +} + +func (m *Message) SetMediaContent(content []MediaContent) { + jsonContent, _ := json.Marshal(content) + m.Content = jsonContent + m.parsedContent = nil + m.parsedStringContent = nil +} + +func (m *Message) IsStringContent() bool { + if m.parsedStringContent != nil { + return true + } + var stringContent string + if err := json.Unmarshal(m.Content, &stringContent); err == nil { + m.parsedStringContent = &stringContent + return true + } + return false +} + +func (m *Message) ParseContent() []MediaContent { + if m.parsedContent != nil { + return m.parsedContent + } + + var contentList []MediaContent + + // 先尝试解析为字符串 + var stringContent string + if err := json.Unmarshal(m.Content, &stringContent); err == nil { + contentList = []MediaContent{{ + Type: ContentTypeText, + Text: stringContent, + }} + m.parsedContent = contentList + return contentList + } + + // 尝试解析为数组 + var arrayContent []map[string]interface{} + if err := json.Unmarshal(m.Content, &arrayContent); err == nil { + for _, contentItem := range arrayContent { + contentType, ok := contentItem["type"].(string) + if !ok { + continue + } + + switch contentType { + case ContentTypeText: + if text, ok := contentItem["text"].(string); ok { + contentList = append(contentList, MediaContent{ + Type: ContentTypeText, + Text: text, + }) + } + + case ContentTypeImageURL: + imageUrl := contentItem["image_url"] + temp := &MessageImageUrl{ + Detail: "high", + } + switch v := imageUrl.(type) { + case string: + temp.Url = v + case map[string]interface{}: + url, ok1 := v["url"].(string) + detail, ok2 := v["detail"].(string) + if ok2 { + temp.Detail = detail + } + if ok1 { + temp.Url = url + } + } + contentList = append(contentList, MediaContent{ + Type: ContentTypeImageURL, + ImageUrl: temp, + }) + + case ContentTypeInputAudio: + if audioData, ok := contentItem["input_audio"].(map[string]interface{}); ok { + data, ok1 := audioData["data"].(string) + format, ok2 := audioData["format"].(string) + if ok1 && ok2 { + temp := &MessageInputAudio{ + Data: data, + Format: format, + } + contentList = append(contentList, MediaContent{ + Type: ContentTypeInputAudio, + InputAudio: temp, + }) + } + } + case ContentTypeFile: + if fileData, ok := contentItem["file"].(map[string]interface{}); ok { + fileId, ok3 := fileData["file_id"].(string) + if ok3 { + contentList = append(contentList, MediaContent{ + Type: ContentTypeFile, + File: &MessageFile{ + FileId: fileId, + }, + }) + } else { + fileName, ok1 := fileData["filename"].(string) + fileDataStr, ok2 := fileData["file_data"].(string) + if ok1 && ok2 { + contentList = append(contentList, MediaContent{ + Type: ContentTypeFile, + File: &MessageFile{ + FileName: fileName, + FileData: fileDataStr, + }, + }) + } + } + } + case ContentTypeVideoUrl: + if videoUrl, ok := contentItem["video_url"].(string); ok { + contentList = append(contentList, MediaContent{ + Type: ContentTypeVideoUrl, + VideoUrl: &MessageVideoUrl{ + Url: videoUrl, + }, + }) + } + } + } + } + + if len(contentList) > 0 { + m.parsedContent = contentList + } + return contentList +}*/ + +type WebSearchOptions struct { + SearchContextSize string `json:"search_context_size,omitempty"` + UserLocation json.RawMessage `json:"user_location,omitempty"` +} + +// https://platform.openai.com/docs/api-reference/responses/create +type OpenAIResponsesRequest struct { + Model string `json:"model"` + Input json.RawMessage `json:"input,omitempty"` + Include json.RawMessage `json:"include,omitempty"` + Instructions json.RawMessage `json:"instructions,omitempty"` + MaxOutputTokens uint `json:"max_output_tokens,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` + PreviousResponseID string `json:"previous_response_id,omitempty"` + Reasoning *Reasoning `json:"reasoning,omitempty"` + // 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤 + ServiceTier string `json:"service_tier,omitempty"` + Store json.RawMessage `json:"store,omitempty"` + PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` + PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + Text json.RawMessage `json:"text,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map + TopP float64 `json:"top_p,omitempty"` + Truncation string `json:"truncation,omitempty"` + User string `json:"user,omitempty"` + MaxToolCalls uint `json:"max_tool_calls,omitempty"` + Prompt json.RawMessage `json:"prompt,omitempty"` +} + +func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta { + var fileMeta = make([]*types.FileMeta, 0) + var texts = make([]string, 0) + + if r.Input != nil { + inputs := r.ParseInput() + for _, input := range inputs { + if input.Type == "input_image" { + if input.ImageUrl != "" { + fileMeta = append(fileMeta, &types.FileMeta{ + FileType: types.FileTypeImage, + OriginData: input.ImageUrl, + Detail: input.Detail, + }) + } + } else if input.Type == "input_file" { + if input.FileUrl != "" { + fileMeta = append(fileMeta, &types.FileMeta{ + FileType: types.FileTypeFile, + OriginData: input.FileUrl, + }) + } + } else { + texts = append(texts, input.Text) + } + } + } + + if len(r.Instructions) > 0 { + texts = append(texts, string(r.Instructions)) + } + + if len(r.Metadata) > 0 { + texts = append(texts, string(r.Metadata)) + } + + if len(r.Text) > 0 { + texts = append(texts, string(r.Text)) + } + + if len(r.ToolChoice) > 0 { + texts = append(texts, string(r.ToolChoice)) + } + + if len(r.Prompt) > 0 { + texts = append(texts, string(r.Prompt)) + } + + if len(r.Tools) > 0 { + texts = append(texts, string(r.Tools)) + } + + return &types.TokenCountMeta{ + CombineText: strings.Join(texts, "\n"), + Files: fileMeta, + MaxTokens: int(r.MaxOutputTokens), + } +} + +func (r *OpenAIResponsesRequest) IsStream(c *gin.Context) bool { + return r.Stream +} + +func (r *OpenAIResponsesRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} + +func (r *OpenAIResponsesRequest) GetToolsMap() []map[string]any { + var toolsMap []map[string]any + if len(r.Tools) > 0 { + _ = common.Unmarshal(r.Tools, &toolsMap) + } + return toolsMap +} + +type Reasoning struct { + Effort string `json:"effort,omitempty"` + Summary string `json:"summary,omitempty"` +} + +type Input struct { + Type string `json:"type,omitempty"` + Role string `json:"role,omitempty"` + Content json.RawMessage `json:"content,omitempty"` +} + +type MediaInput struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + FileUrl string `json:"file_url,omitempty"` + ImageUrl string `json:"image_url,omitempty"` + Detail string `json:"detail,omitempty"` // 仅 input_image 有效 +} + +// ParseInput parses the Responses API `input` field into a normalized slice of MediaInput. +// Reference implementation mirrors Message.ParseContent: +// - input can be a string, treated as an input_text item +// - input can be an array of objects with a `type` field +// supported types: input_text, input_image, input_file +func (r *OpenAIResponsesRequest) ParseInput() []MediaInput { + if r.Input == nil { + return nil + } + + var mediaInputs []MediaInput + + // Try string first + // if str, ok := common.GetJsonType(r.Input); ok { + // inputs = append(inputs, MediaInput{Type: "input_text", Text: str}) + // return inputs + // } + if common.GetJsonType(r.Input) == "string" { + var str string + _ = common.Unmarshal(r.Input, &str) + mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: str}) + return mediaInputs + } + + // Try array of parts + if common.GetJsonType(r.Input) == "array" { + var inputs []Input + _ = common.Unmarshal(r.Input, &inputs) + for _, input := range inputs { + if common.GetJsonType(input.Content) == "string" { + var str string + _ = common.Unmarshal(input.Content, &str) + mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: str}) + } + + if common.GetJsonType(input.Content) == "array" { + var array []any + _ = common.Unmarshal(input.Content, &array) + for _, itemAny := range array { + // Already parsed MediaContent + if media, ok := itemAny.(MediaInput); ok { + mediaInputs = append(mediaInputs, media) + continue + } + + // Generic map + item, ok := itemAny.(map[string]any) + if !ok { + continue + } + + typeVal, ok := item["type"].(string) + if !ok { + continue + } + switch typeVal { + case "input_text": + text, _ := item["text"].(string) + mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: text}) + case "input_image": + // image_url may be string or object with url field + var imageUrl string + switch v := item["image_url"].(type) { + case string: + imageUrl = v + case map[string]any: + if url, ok := v["url"].(string); ok { + imageUrl = url + } + } + mediaInputs = append(mediaInputs, MediaInput{Type: "input_image", ImageUrl: imageUrl}) + case "input_file": + // file_url may be string or object with url field + var fileUrl string + switch v := item["file_url"].(type) { + case string: + fileUrl = v + case map[string]any: + if url, ok := v["url"].(string); ok { + fileUrl = url + } + } + mediaInputs = append(mediaInputs, MediaInput{Type: "input_file", FileUrl: fileUrl}) + } + } + } + } + } + + return mediaInputs +} diff --git a/dto/openai_response.go b/dto/openai_response.go new file mode 100644 index 0000000000000000000000000000000000000000..6baee78ccdbaa7de5f2f8994d824e707ac495681 --- /dev/null +++ b/dto/openai_response.go @@ -0,0 +1,414 @@ +package dto + +import ( + "encoding/json" + "fmt" + + "github.com/QuantumNous/new-api/types" +) + +const ( + ResponsesOutputTypeImageGenerationCall = "image_generation_call" +) + +type SimpleResponse struct { + Usage `json:"usage"` + Error any `json:"error"` +} + +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func (s *SimpleResponse) GetOpenAIError() *types.OpenAIError { + return GetOpenAIError(s.Error) +} + +type TextResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []OpenAITextResponseChoice `json:"choices"` + Usage `json:"usage"` +} + +type OpenAITextResponseChoice struct { + Index int `json:"index"` + Message `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type OpenAITextResponse struct { + Id string `json:"id"` + Model string `json:"model"` + Object string `json:"object"` + Created any `json:"created"` + Choices []OpenAITextResponseChoice `json:"choices"` + Error any `json:"error,omitempty"` + Usage `json:"usage"` +} + +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func (o *OpenAITextResponse) GetOpenAIError() *types.OpenAIError { + return GetOpenAIError(o.Error) +} + +type OpenAIEmbeddingResponseItem struct { + Object string `json:"object"` + Index int `json:"index"` + Embedding []float64 `json:"embedding"` +} + +type OpenAIEmbeddingResponse struct { + Object string `json:"object"` + Data []OpenAIEmbeddingResponseItem `json:"data"` + Model string `json:"model"` + Usage `json:"usage"` +} + +type FlexibleEmbeddingResponseItem struct { + Object string `json:"object"` + Index int `json:"index"` + Embedding any `json:"embedding"` +} + +type FlexibleEmbeddingResponse struct { + Object string `json:"object"` + Data []FlexibleEmbeddingResponseItem `json:"data"` + Model string `json:"model"` + Usage `json:"usage"` +} + +type ChatCompletionsStreamResponseChoice struct { + Delta ChatCompletionsStreamResponseChoiceDelta `json:"delta,omitempty"` + Logprobs *any `json:"logprobs"` + FinishReason *string `json:"finish_reason"` + Index int `json:"index"` +} + +type ChatCompletionsStreamResponseChoiceDelta struct { + Content *string `json:"content,omitempty"` + ReasoningContent *string `json:"reasoning_content,omitempty"` + Reasoning *string `json:"reasoning,omitempty"` + Role string `json:"role,omitempty"` + ToolCalls []ToolCallResponse `json:"tool_calls,omitempty"` +} + +func (c *ChatCompletionsStreamResponseChoiceDelta) SetContentString(s string) { + c.Content = &s +} + +func (c *ChatCompletionsStreamResponseChoiceDelta) GetContentString() string { + if c.Content == nil { + return "" + } + return *c.Content +} + +func (c *ChatCompletionsStreamResponseChoiceDelta) GetReasoningContent() string { + if c.ReasoningContent == nil && c.Reasoning == nil { + return "" + } + if c.ReasoningContent != nil { + return *c.ReasoningContent + } + return *c.Reasoning +} + +func (c *ChatCompletionsStreamResponseChoiceDelta) SetReasoningContent(s string) { + c.ReasoningContent = &s + //c.Reasoning = &s +} + +type ToolCallResponse struct { + // Index is not nil only in chat completion chunk object + Index *int `json:"index,omitempty"` + ID string `json:"id,omitempty"` + Type any `json:"type"` + Function FunctionResponse `json:"function"` +} + +func (c *ToolCallResponse) SetIndex(i int) { + c.Index = &i +} + +type FunctionResponse struct { + Description string `json:"description,omitempty"` + Name string `json:"name,omitempty"` + // call function with arguments in JSON format + Parameters any `json:"parameters,omitempty"` // request + Arguments string `json:"arguments"` // response +} + +type ChatCompletionsStreamResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + SystemFingerprint *string `json:"system_fingerprint"` + Choices []ChatCompletionsStreamResponseChoice `json:"choices"` + Usage *Usage `json:"usage"` +} + +func (c *ChatCompletionsStreamResponse) IsFinished() bool { + if len(c.Choices) == 0 { + return false + } + return c.Choices[0].FinishReason != nil && *c.Choices[0].FinishReason != "" +} + +func (c *ChatCompletionsStreamResponse) IsToolCall() bool { + if len(c.Choices) == 0 { + return false + } + return len(c.Choices[0].Delta.ToolCalls) > 0 +} + +func (c *ChatCompletionsStreamResponse) GetFirstToolCall() *ToolCallResponse { + if c.IsToolCall() { + return &c.Choices[0].Delta.ToolCalls[0] + } + return nil +} + +func (c *ChatCompletionsStreamResponse) ClearToolCalls() { + if !c.IsToolCall() { + return + } + for choiceIdx := range c.Choices { + for callIdx := range c.Choices[choiceIdx].Delta.ToolCalls { + c.Choices[choiceIdx].Delta.ToolCalls[callIdx].ID = "" + c.Choices[choiceIdx].Delta.ToolCalls[callIdx].Type = nil + c.Choices[choiceIdx].Delta.ToolCalls[callIdx].Function.Name = "" + } + } +} + +func (c *ChatCompletionsStreamResponse) Copy() *ChatCompletionsStreamResponse { + choices := make([]ChatCompletionsStreamResponseChoice, len(c.Choices)) + copy(choices, c.Choices) + return &ChatCompletionsStreamResponse{ + Id: c.Id, + Object: c.Object, + Created: c.Created, + Model: c.Model, + SystemFingerprint: c.SystemFingerprint, + Choices: choices, + Usage: c.Usage, + } +} + +func (c *ChatCompletionsStreamResponse) GetSystemFingerprint() string { + if c.SystemFingerprint == nil { + return "" + } + return *c.SystemFingerprint +} + +func (c *ChatCompletionsStreamResponse) SetSystemFingerprint(s string) { + c.SystemFingerprint = &s +} + +type ChatCompletionsStreamResponseSimple struct { + Choices []ChatCompletionsStreamResponseChoice `json:"choices"` + Usage *Usage `json:"usage"` +} + +type CompletionsStreamResponse struct { + Choices []struct { + Text string `json:"text"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` +} + +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"` + + PromptTokensDetails InputTokenDetails `json:"prompt_tokens_details"` + CompletionTokenDetails OutputTokenDetails `json:"completion_tokens_details"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + InputTokensDetails *InputTokenDetails `json:"input_tokens_details"` + + // claude cache 1h + ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"` + ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"` + + // OpenRouter Params + Cost any `json:"cost,omitempty"` +} + +type OpenAIVideoResponse struct { + Id string `json:"id" example:"file-abc123"` + Object string `json:"object" example:"file"` + Bytes int64 `json:"bytes" example:"120000"` + CreatedAt int64 `json:"created_at" example:"1677610602"` + ExpiresAt int64 `json:"expires_at" example:"1677614202"` + Filename string `json:"filename" example:"mydata.jsonl"` + Purpose string `json:"purpose" example:"fine-tune"` +} + +type InputTokenDetails struct { + CachedTokens int `json:"cached_tokens"` + CachedCreationTokens int `json:"-"` + TextTokens int `json:"text_tokens"` + AudioTokens int `json:"audio_tokens"` + ImageTokens int `json:"image_tokens"` +} + +type OutputTokenDetails struct { + TextTokens int `json:"text_tokens"` + AudioTokens int `json:"audio_tokens"` + ReasoningTokens int `json:"reasoning_tokens"` +} + +type OpenAIResponsesResponse struct { + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int `json:"created_at"` + Status string `json:"status"` + Error any `json:"error,omitempty"` + IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"` + Instructions string `json:"instructions"` + MaxOutputTokens int `json:"max_output_tokens"` + Model string `json:"model"` + Output []ResponsesOutput `json:"output"` + ParallelToolCalls bool `json:"parallel_tool_calls"` + PreviousResponseID string `json:"previous_response_id"` + Reasoning *Reasoning `json:"reasoning"` + Store bool `json:"store"` + Temperature float64 `json:"temperature"` + ToolChoice string `json:"tool_choice"` + Tools []map[string]any `json:"tools"` + TopP float64 `json:"top_p"` + Truncation string `json:"truncation"` + Usage *Usage `json:"usage"` + User json.RawMessage `json:"user"` + Metadata json.RawMessage `json:"metadata"` +} + +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError { + return GetOpenAIError(o.Error) +} + +func (o *OpenAIResponsesResponse) HasImageGenerationCall() bool { + if len(o.Output) == 0 { + return false + } + for _, output := range o.Output { + if output.Type == ResponsesOutputTypeImageGenerationCall { + return true + } + } + return false +} + +func (o *OpenAIResponsesResponse) GetQuality() string { + if len(o.Output) == 0 { + return "" + } + for _, output := range o.Output { + if output.Type == ResponsesOutputTypeImageGenerationCall { + return output.Quality + } + } + return "" +} + +func (o *OpenAIResponsesResponse) GetSize() string { + if len(o.Output) == 0 { + return "" + } + for _, output := range o.Output { + if output.Type == ResponsesOutputTypeImageGenerationCall { + return output.Size + } + } + return "" +} + +type IncompleteDetails struct { + Reasoning string `json:"reasoning"` +} + +type ResponsesOutput struct { + Type string `json:"type"` + ID string `json:"id"` + Status string `json:"status"` + Role string `json:"role"` + Content []ResponsesOutputContent `json:"content"` + Quality string `json:"quality"` + Size string `json:"size"` +} + +type ResponsesOutputContent struct { + Type string `json:"type"` + Text string `json:"text"` + Annotations []interface{} `json:"annotations"` +} + +const ( + BuildInToolWebSearchPreview = "web_search_preview" + BuildInToolFileSearch = "file_search" +) + +const ( + BuildInCallWebSearchCall = "web_search_call" +) + +const ( + ResponsesOutputTypeItemAdded = "response.output_item.added" + ResponsesOutputTypeItemDone = "response.output_item.done" +) + +// ResponsesStreamResponse 用于处理 /v1/responses 流式响应 +type ResponsesStreamResponse struct { + Type string `json:"type"` + Response *OpenAIResponsesResponse `json:"response,omitempty"` + Delta string `json:"delta,omitempty"` + Item *ResponsesOutput `json:"item,omitempty"` +} + +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func GetOpenAIError(errorField any) *types.OpenAIError { + if errorField == nil { + return nil + } + + switch err := errorField.(type) { + case types.OpenAIError: + return &err + case *types.OpenAIError: + return err + case map[string]interface{}: + // 处理从JSON解析来的map结构 + openaiErr := &types.OpenAIError{} + if errType, ok := err["type"].(string); ok { + openaiErr.Type = errType + } + if errMsg, ok := err["message"].(string); ok { + openaiErr.Message = errMsg + } + if errParam, ok := err["param"].(string); ok { + openaiErr.Param = errParam + } + if errCode, ok := err["code"]; ok { + openaiErr.Code = errCode + } + return openaiErr + case string: + // 处理简单字符串错误 + return &types.OpenAIError{ + Type: "error", + Message: err, + } + default: + // 未知类型,尝试转换为字符串 + return &types.OpenAIError{ + Type: "unknown_error", + Message: fmt.Sprintf("%v", err), + } + } +} diff --git a/dto/openai_video.go b/dto/openai_video.go new file mode 100644 index 0000000000000000000000000000000000000000..bee64c31f960b5c7298d66e38bc7df836c1c5c25 --- /dev/null +++ b/dto/openai_video.go @@ -0,0 +1,52 @@ +package dto + +import ( + "strconv" + "strings" +) + +const ( + VideoStatusUnknown = "unknown" + VideoStatusQueued = "queued" + VideoStatusInProgress = "in_progress" + VideoStatusCompleted = "completed" + VideoStatusFailed = "failed" +) + +type OpenAIVideo struct { + ID string `json:"id"` + TaskID string `json:"task_id,omitempty"` //兼容旧接口 待废弃 + Object string `json:"object"` + Model string `json:"model"` + Status string `json:"status"` // Should use VideoStatus constants: VideoStatusQueued, VideoStatusInProgress, VideoStatusCompleted, VideoStatusFailed + Progress int `json:"progress"` + CreatedAt int64 `json:"created_at"` + CompletedAt int64 `json:"completed_at,omitempty"` + ExpiresAt int64 `json:"expires_at,omitempty"` + Seconds string `json:"seconds,omitempty"` + Size string `json:"size,omitempty"` + RemixedFromVideoID string `json:"remixed_from_video_id,omitempty"` + Error *OpenAIVideoError `json:"error,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +func (m *OpenAIVideo) SetProgressStr(progress string) { + progress = strings.TrimSuffix(progress, "%") + m.Progress, _ = strconv.Atoi(progress) +} +func (m *OpenAIVideo) SetMetadata(k string, v any) { + if m.Metadata == nil { + m.Metadata = make(map[string]any) + } + m.Metadata[k] = v +} +func NewOpenAIVideo() *OpenAIVideo { + return &OpenAIVideo{ + Object: "video", + } +} + +type OpenAIVideoError struct { + Message string `json:"message"` + Code string `json:"code"` +} diff --git a/dto/playground.go b/dto/playground.go new file mode 100644 index 0000000000000000000000000000000000000000..47eddaec963c7161089def1adb62b7a855feaecb --- /dev/null +++ b/dto/playground.go @@ -0,0 +1,6 @@ +package dto + +type PlayGroundRequest struct { + Model string `json:"model,omitempty"` + Group string `json:"group,omitempty"` +} diff --git a/dto/pricing.go b/dto/pricing.go new file mode 100644 index 0000000000000000000000000000000000000000..1ed8dcd31c29cd37d3414b889f7b17c2553bae99 --- /dev/null +++ b/dto/pricing.go @@ -0,0 +1,35 @@ +package dto + +import "github.com/QuantumNous/new-api/constant" + +// 这里不好动就不动了,本来想独立出来的( +type OpenAIModels struct { + Id string `json:"id"` + Object string `json:"object"` + Created int `json:"created"` + OwnedBy string `json:"owned_by"` + SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` +} + +type AnthropicModel struct { + ID string `json:"id"` + CreatedAt string `json:"created_at"` + DisplayName string `json:"display_name"` + Type string `json:"type"` +} + +type GeminiModel struct { + Name interface{} `json:"name"` + BaseModelId interface{} `json:"baseModelId"` + Version interface{} `json:"version"` + DisplayName interface{} `json:"displayName"` + Description interface{} `json:"description"` + InputTokenLimit interface{} `json:"inputTokenLimit"` + OutputTokenLimit interface{} `json:"outputTokenLimit"` + SupportedGenerationMethods []interface{} `json:"supportedGenerationMethods"` + Thinking interface{} `json:"thinking"` + Temperature interface{} `json:"temperature"` + MaxTemperature interface{} `json:"maxTemperature"` + TopP interface{} `json:"topP"` + TopK interface{} `json:"topK"` +} diff --git a/dto/ratio_sync.go b/dto/ratio_sync.go new file mode 100644 index 0000000000000000000000000000000000000000..d6bbf68e1069bf58cde2b637fef53ca25d9cb0b8 --- /dev/null +++ b/dto/ratio_sync.go @@ -0,0 +1,38 @@ +package dto + +type UpstreamDTO struct { + ID int `json:"id,omitempty"` + Name string `json:"name" binding:"required"` + BaseURL string `json:"base_url" binding:"required"` + Endpoint string `json:"endpoint"` +} + +type UpstreamRequest struct { + ChannelIDs []int64 `json:"channel_ids"` + Upstreams []UpstreamDTO `json:"upstreams"` + Timeout int `json:"timeout"` +} + +// TestResult 上游测试连通性结果 +type TestResult struct { + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// DifferenceItem 差异项 +// Current 为本地值,可能为 nil +// Upstreams 为各渠道的上游值,具体数值 / "same" / nil + +type DifferenceItem struct { + Current interface{} `json:"current"` + Upstreams map[string]interface{} `json:"upstreams"` + Confidence map[string]bool `json:"confidence"` +} + +type SyncableChannel struct { + ID int `json:"id"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + Status int `json:"status"` +} diff --git a/dto/realtime.go b/dto/realtime.go new file mode 100644 index 0000000000000000000000000000000000000000..0fbfb86f0d7c9b2d29dc8d27e0efcde12131e6c0 --- /dev/null +++ b/dto/realtime.go @@ -0,0 +1,88 @@ +package dto + +import "github.com/QuantumNous/new-api/types" + +const ( + RealtimeEventTypeError = "error" + RealtimeEventTypeSessionUpdate = "session.update" + RealtimeEventTypeConversationCreate = "conversation.item.create" + RealtimeEventTypeResponseCreate = "response.create" + RealtimeEventInputAudioBufferAppend = "input_audio_buffer.append" +) + +const ( + RealtimeEventTypeResponseDone = "response.done" + RealtimeEventTypeSessionUpdated = "session.updated" + RealtimeEventTypeSessionCreated = "session.created" + RealtimeEventResponseAudioDelta = "response.audio.delta" + RealtimeEventResponseAudioTranscriptionDelta = "response.audio_transcript.delta" + RealtimeEventResponseFunctionCallArgumentsDelta = "response.function_call_arguments.delta" + RealtimeEventResponseFunctionCallArgumentsDone = "response.function_call_arguments.done" + RealtimeEventConversationItemCreated = "conversation.item.created" +) + +type RealtimeEvent struct { + EventId string `json:"event_id"` + Type string `json:"type"` + //PreviousItemId string `json:"previous_item_id"` + Session *RealtimeSession `json:"session,omitempty"` + Item *RealtimeItem `json:"item,omitempty"` + Error *types.OpenAIError `json:"error,omitempty"` + Response *RealtimeResponse `json:"response,omitempty"` + Delta string `json:"delta,omitempty"` + Audio string `json:"audio,omitempty"` +} + +type RealtimeResponse struct { + Usage *RealtimeUsage `json:"usage"` +} + +type RealtimeUsage struct { + TotalTokens int `json:"total_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + InputTokenDetails InputTokenDetails `json:"input_token_details"` + OutputTokenDetails OutputTokenDetails `json:"output_token_details"` +} + +type RealtimeSession struct { + Modalities []string `json:"modalities"` + Instructions string `json:"instructions"` + Voice string `json:"voice"` + InputAudioFormat string `json:"input_audio_format"` + OutputAudioFormat string `json:"output_audio_format"` + InputAudioTranscription InputAudioTranscription `json:"input_audio_transcription"` + TurnDetection interface{} `json:"turn_detection"` + Tools []RealTimeTool `json:"tools"` + ToolChoice string `json:"tool_choice"` + Temperature float64 `json:"temperature"` + //MaxResponseOutputTokens int `json:"max_response_output_tokens"` +} + +type InputAudioTranscription struct { + Model string `json:"model"` +} + +type RealTimeTool struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Parameters any `json:"parameters"` +} + +type RealtimeItem struct { + Id string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Role string `json:"role"` + Content []RealtimeContent `json:"content"` + Name *string `json:"name,omitempty"` + ToolCalls any `json:"tool_calls,omitempty"` + CallId string `json:"call_id,omitempty"` +} +type RealtimeContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Audio string `json:"audio,omitempty"` // Base64-encoded audio bytes. + Transcript string `json:"transcript,omitempty"` +} diff --git a/dto/request_common.go b/dto/request_common.go new file mode 100644 index 0000000000000000000000000000000000000000..e6e40c3a1a914cc3ad185a4d0ce6a64106047dd2 --- /dev/null +++ b/dto/request_common.go @@ -0,0 +1,25 @@ +package dto + +import ( + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +type Request interface { + GetTokenCountMeta() *types.TokenCountMeta + IsStream(c *gin.Context) bool + SetModelName(modelName string) +} + +type BaseRequest struct { +} + +func (b *BaseRequest) GetTokenCountMeta() *types.TokenCountMeta { + return &types.TokenCountMeta{ + TokenType: types.TokenTypeTokenizer, + } +} +func (b *BaseRequest) IsStream(c *gin.Context) bool { + return false +} +func (b *BaseRequest) SetModelName(modelName string) {} diff --git a/dto/rerank.go b/dto/rerank.go new file mode 100644 index 0000000000000000000000000000000000000000..607d68a7093fe3201c2d83755a3ea91df4036faa --- /dev/null +++ b/dto/rerank.go @@ -0,0 +1,67 @@ +package dto + +import ( + "fmt" + "strings" + + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +type RerankRequest struct { + Documents []any `json:"documents"` + Query string `json:"query"` + Model string `json:"model"` + TopN int `json:"top_n,omitempty"` + ReturnDocuments *bool `json:"return_documents,omitempty"` + MaxChunkPerDoc int `json:"max_chunk_per_doc,omitempty"` + OverLapTokens int `json:"overlap_tokens,omitempty"` +} + +func (r *RerankRequest) IsStream(c *gin.Context) bool { + return false +} + +func (r *RerankRequest) GetTokenCountMeta() *types.TokenCountMeta { + var texts = make([]string, 0) + + for _, document := range r.Documents { + texts = append(texts, fmt.Sprintf("%v", document)) + } + + if r.Query != "" { + texts = append(texts, r.Query) + } + + return &types.TokenCountMeta{ + CombineText: strings.Join(texts, "\n"), + } +} + +func (r *RerankRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} + +func (r *RerankRequest) GetReturnDocuments() bool { + if r.ReturnDocuments == nil { + return false + } + return *r.ReturnDocuments +} + +type RerankResponseResult struct { + Document any `json:"document,omitempty"` + Index int `json:"index"` + RelevanceScore float64 `json:"relevance_score"` +} + +type RerankDocument struct { + Text any `json:"text"` +} + +type RerankResponse struct { + Results []RerankResponseResult `json:"results"` + Usage Usage `json:"usage"` +} diff --git a/dto/sensitive.go b/dto/sensitive.go new file mode 100644 index 0000000000000000000000000000000000000000..0bfbc6fb00c325af8fe3479a1077ff3fc0876b37 --- /dev/null +++ b/dto/sensitive.go @@ -0,0 +1,6 @@ +package dto + +type SensitiveResponse struct { + SensitiveWords []string `json:"sensitive_words"` + Content string `json:"content"` +} diff --git a/dto/suno.go b/dto/suno.go new file mode 100644 index 0000000000000000000000000000000000000000..a6bb3ebae192aa7bd614e9f4b6cefd7d6072cd9e --- /dev/null +++ b/dto/suno.go @@ -0,0 +1,129 @@ +package dto + +import ( + "encoding/json" +) + +type TaskData interface { + SunoDataResponse | []SunoDataResponse | string | any +} + +type SunoSubmitReq struct { + GptDescriptionPrompt string `json:"gpt_description_prompt,omitempty"` + Prompt string `json:"prompt,omitempty"` + Mv string `json:"mv,omitempty"` + Title string `json:"title,omitempty"` + Tags string `json:"tags,omitempty"` + ContinueAt float64 `json:"continue_at,omitempty"` + TaskID string `json:"task_id,omitempty"` + ContinueClipId string `json:"continue_clip_id,omitempty"` + MakeInstrumental bool `json:"make_instrumental"` +} + +type FetchReq struct { + IDs []string `json:"ids"` +} + +type SunoDataResponse struct { + TaskID string `json:"task_id" gorm:"type:varchar(50);index"` + Action string `json:"action" gorm:"type:varchar(40);index"` // 任务类型, song, lyrics, description-mode + Status string `json:"status" gorm:"type:varchar(20);index"` // 任务状态, submitted, queueing, processing, success, failed + FailReason string `json:"fail_reason"` + SubmitTime int64 `json:"submit_time" gorm:"index"` + StartTime int64 `json:"start_time" gorm:"index"` + FinishTime int64 `json:"finish_time" gorm:"index"` + Data json.RawMessage `json:"data" gorm:"type:json"` +} + +type SunoSong struct { + ID string `json:"id"` + VideoURL string `json:"video_url"` + AudioURL string `json:"audio_url"` + ImageURL string `json:"image_url"` + ImageLargeURL string `json:"image_large_url"` + MajorModelVersion string `json:"major_model_version"` + ModelName string `json:"model_name"` + Status string `json:"status"` + Title string `json:"title"` + Text string `json:"text"` + Metadata SunoMetadata `json:"metadata"` +} + +type SunoMetadata struct { + Tags string `json:"tags"` + Prompt string `json:"prompt"` + GPTDescriptionPrompt interface{} `json:"gpt_description_prompt"` + AudioPromptID interface{} `json:"audio_prompt_id"` + Duration interface{} `json:"duration"` + ErrorType interface{} `json:"error_type"` + ErrorMessage interface{} `json:"error_message"` +} + +type SunoLyrics struct { + ID string `json:"id"` + Status string `json:"status"` + Title string `json:"title"` + Text string `json:"text"` +} + +const TaskSuccessCode = "success" + +type TaskResponse[T TaskData] struct { + Code string `json:"code"` + Message string `json:"message"` + Data T `json:"data"` +} + +func (t *TaskResponse[T]) IsSuccess() bool { + return t.Code == TaskSuccessCode +} + +type TaskDto struct { + TaskID string `json:"task_id"` // 第三方id,不一定有/ song id\ Task id + Action string `json:"action"` // 任务类型, song, lyrics, description-mode + Status string `json:"status"` // 任务状态, submitted, queueing, processing, success, failed + FailReason string `json:"fail_reason"` + SubmitTime int64 `json:"submit_time"` + StartTime int64 `json:"start_time"` + FinishTime int64 `json:"finish_time"` + Progress string `json:"progress"` + Data json.RawMessage `json:"data"` +} + +type SunoGoAPISubmitReq struct { + CustomMode bool `json:"custom_mode"` + + Input SunoGoAPISubmitReqInput `json:"input"` + + NotifyHook string `json:"notify_hook,omitempty"` +} + +type SunoGoAPISubmitReqInput struct { + GptDescriptionPrompt string `json:"gpt_description_prompt"` + Prompt string `json:"prompt"` + Mv string `json:"mv"` + Title string `json:"title"` + Tags string `json:"tags"` + ContinueAt float64 `json:"continue_at"` + TaskID string `json:"task_id"` + ContinueClipId string `json:"continue_clip_id"` + MakeInstrumental bool `json:"make_instrumental"` +} + +type GoAPITaskResponse[T any] struct { + Code int `json:"code"` + Message string `json:"message"` + Data T `json:"data"` + ErrorMessage string `json:"error_message,omitempty"` +} + +type GoAPITaskResponseData struct { + TaskID string `json:"task_id"` +} + +type GoAPIFetchResponseData struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + Input string `json:"input"` + Clips map[string]SunoSong `json:"clips"` +} diff --git a/dto/task.go b/dto/task.go new file mode 100644 index 0000000000000000000000000000000000000000..afc186b41d3351bd2d63cf218357f33603ed28c2 --- /dev/null +++ b/dto/task.go @@ -0,0 +1,10 @@ +package dto + +type TaskError struct { + Code string `json:"code"` + Message string `json:"message"` + Data any `json:"data"` + StatusCode int `json:"-"` + LocalError bool `json:"-"` + Error error `json:"-"` +} diff --git a/dto/user_settings.go b/dto/user_settings.go new file mode 100644 index 0000000000000000000000000000000000000000..16ce7b9851af12f5c15c54063f8ca574dfa8b30f --- /dev/null +++ b/dto/user_settings.go @@ -0,0 +1,23 @@ +package dto + +type UserSetting struct { + NotifyType string `json:"notify_type,omitempty"` // QuotaWarningType 额度预警类型 + QuotaWarningThreshold float64 `json:"quota_warning_threshold,omitempty"` // QuotaWarningThreshold 额度预警阈值 + WebhookUrl string `json:"webhook_url,omitempty"` // WebhookUrl webhook地址 + WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥 + NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址 + BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL + GotifyUrl string `json:"gotify_url,omitempty"` // GotifyUrl Gotify服务器地址 + GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌 + GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级 + AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型 + RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP + SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置 +} + +var ( + NotifyTypeEmail = "email" // Email 邮件 + NotifyTypeWebhook = "webhook" // Webhook + NotifyTypeBark = "bark" // Bark 推送 + NotifyTypeGotify = "gotify" // Gotify 推送 +) diff --git a/dto/video.go b/dto/video.go new file mode 100644 index 0000000000000000000000000000000000000000..5b48146a23233dbd7e635d37892b15dd344827d1 --- /dev/null +++ b/dto/video.go @@ -0,0 +1,47 @@ +package dto + +type VideoRequest struct { + Model string `json:"model,omitempty" example:"kling-v1"` // Model/style ID + Prompt string `json:"prompt,omitempty" example:"宇航员站起身走了"` // Text prompt + Image string `json:"image,omitempty" example:"https://h2.inkwai.com/bs2/upload-ylab-stunt/se/ai_portal_queue_mmu_image_upscale_aiweb/3214b798-e1b4-4b00-b7af-72b5b0417420_raw_image_0.jpg"` // Image input (URL/Base64) + Duration float64 `json:"duration" example:"5.0"` // Video duration (seconds) + Width int `json:"width" example:"512"` // Video width + Height int `json:"height" example:"512"` // Video height + Fps int `json:"fps,omitempty" example:"30"` // Video frame rate + Seed int `json:"seed,omitempty" example:"20231234"` // Random seed + N int `json:"n,omitempty" example:"1"` // Number of videos to generate + ResponseFormat string `json:"response_format,omitempty" example:"url"` // Response format + User string `json:"user,omitempty" example:"user-1234"` // User identifier + Metadata map[string]any `json:"metadata,omitempty"` // Vendor-specific/custom params (e.g. negative_prompt, style, quality_level, etc.) +} + +// VideoResponse 视频生成提交任务后的响应 +type VideoResponse struct { + TaskId string `json:"task_id"` + Status string `json:"status"` +} + +// VideoTaskResponse 查询视频生成任务状态的响应 +type VideoTaskResponse struct { + TaskId string `json:"task_id" example:"abcd1234efgh"` // 任务ID + Status string `json:"status" example:"succeeded"` // 任务状态 + Url string `json:"url,omitempty"` // 视频资源URL(成功时) + Format string `json:"format,omitempty" example:"mp4"` // 视频格式 + Metadata *VideoTaskMetadata `json:"metadata,omitempty"` // 结果元数据 + Error *VideoTaskError `json:"error,omitempty"` // 错误信息(失败时) +} + +// VideoTaskMetadata 视频任务元数据 +type VideoTaskMetadata struct { + Duration float64 `json:"duration" example:"5.0"` // 实际生成的视频时长 + Fps int `json:"fps" example:"30"` // 实际帧率 + Width int `json:"width" example:"512"` // 实际宽度 + Height int `json:"height" example:"512"` // 实际高度 + Seed int `json:"seed" example:"20231234"` // 使用的随机种子 +} + +// VideoTaskError 视频任务错误信息 +type VideoTaskError struct { + Code int `json:"code"` + Message string `json:"message"` +} diff --git a/electron/README.md b/electron/README.md new file mode 100644 index 0000000000000000000000000000000000000000..88463b8aefd9f081bb8c96a574c6122eab3bedb4 --- /dev/null +++ b/electron/README.md @@ -0,0 +1,73 @@ +# New API Electron Desktop App + +This directory contains the Electron wrapper for New API, providing a native desktop application with system tray support for Windows, macOS, and Linux. + +## Prerequisites + +### 1. Go Binary (Required) +The Electron app requires the compiled Go binary to function. You have two options: + +**Option A: Use existing binary (without Go installed)** +```bash +# If you have a pre-built binary (e.g., new-api-macos) +cp ../new-api-macos ../new-api +``` + +**Option B: Build from source (requires Go)** +TODO + +### 3. Electron Dependencies +```bash +cd electron +npm install +``` + +## Development + +Run the app in development mode: +```bash +npm start +``` + +This will: +- Start the Go backend on port 3000 +- Open an Electron window with DevTools enabled +- Create a system tray icon (menu bar on macOS) +- Store database in `../data/new-api.db` + +## Building for Production + +### Quick Build +```bash +# Ensure Go binary exists in parent directory +ls ../new-api # Should exist + +# Build for current platform +npm run build + +# Platform-specific builds +npm run build:mac # Creates .dmg and .zip +npm run build:win # Creates .exe installer +npm run build:linux # Creates .AppImage and .deb +``` + +### Build Output +- Built applications are in `electron/dist/` +- macOS: `.dmg` (installer) and `.zip` (portable) +- Windows: `.exe` (installer) and portable exe +- Linux: `.AppImage` and `.deb` + +## Configuration + +### Port +Default port is 3000. To change, edit `main.js`: +```javascript +const PORT = 3000; // Change to desired port +``` + +### Database Location +- **Development**: `../data/new-api.db` (project directory) +- **Production**: + - macOS: `~/Library/Application Support/New API/data/` + - Windows: `%APPDATA%/New API/data/` + - Linux: `~/.config/New API/data/` diff --git a/electron/build.sh b/electron/build.sh new file mode 100755 index 0000000000000000000000000000000000000000..cef714328d5a16c5ecdcbcc909fe1150041bf627 --- /dev/null +++ b/electron/build.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +set -e + +echo "Building New API Electron App..." + +echo "Step 1: Building frontend..." +cd ../web +DISABLE_ESLINT_PLUGIN='true' bun run build +cd ../electron + +echo "Step 2: Building Go backend..." +cd .. + +if [[ "$OSTYPE" == "darwin"* ]]; then + echo "Building for macOS..." + CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api + cd electron + npm install + npm run build:mac +elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + echo "Building for Linux..." + CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api + cd electron + npm install + npm run build:linux +elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]]; then + echo "Building for Windows..." + CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api.exe + cd electron + npm install + npm run build:win +else + echo "Unknown OS, building for current platform..." + CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api + cd electron + npm install + npm run build +fi + +echo "Build complete! Check electron/dist/ for output." \ No newline at end of file diff --git a/electron/create-tray-icon.js b/electron/create-tray-icon.js new file mode 100644 index 0000000000000000000000000000000000000000..517393b2e1d6fb9dcb23366b9b535eee92c5e680 --- /dev/null +++ b/electron/create-tray-icon.js @@ -0,0 +1,60 @@ +// Create a simple tray icon for macOS +// Run: node create-tray-icon.js + +const fs = require('fs'); +const { createCanvas } = require('canvas'); + +function createTrayIcon() { + // For macOS, we'll use a Template image (black and white) + // Size should be 22x22 for Retina displays (@2x would be 44x44) + const canvas = createCanvas(22, 22); + const ctx = canvas.getContext('2d'); + + // Clear canvas + ctx.clearRect(0, 0, 22, 22); + + // Draw a simple "API" icon + ctx.fillStyle = '#000000'; + ctx.font = 'bold 10px system-ui'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('API', 11, 11); + + // Save as PNG + const buffer = canvas.toBuffer('image/png'); + fs.writeFileSync('tray-icon.png', buffer); + + // For Template images on macOS (will adapt to menu bar theme) + fs.writeFileSync('tray-iconTemplate.png', buffer); + fs.writeFileSync('tray-iconTemplate@2x.png', buffer); + + console.log('Tray icon created successfully!'); +} + +// Check if canvas is installed +try { + createTrayIcon(); +} catch (err) { + console.log('Canvas module not installed.'); + console.log('For now, creating a placeholder. Install canvas with: npm install canvas'); + + // Create a minimal 1x1 transparent PNG as placeholder + const minimalPNG = Buffer.from([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x01, 0x03, 0x00, 0x00, 0x00, 0x25, 0xDB, 0x56, + 0xCA, 0x00, 0x00, 0x00, 0x03, 0x50, 0x4C, 0x54, + 0x45, 0x00, 0x00, 0x00, 0xA7, 0x7A, 0x3D, 0xDA, + 0x00, 0x00, 0x00, 0x01, 0x74, 0x52, 0x4E, 0x53, + 0x00, 0x40, 0xE6, 0xD8, 0x66, 0x00, 0x00, 0x00, + 0x0A, 0x49, 0x44, 0x41, 0x54, 0x08, 0x1D, 0x62, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x01, 0x0A, 0x2D, 0xCB, 0x59, 0x00, 0x00, + 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, + 0x60, 0x82 + ]); + + fs.writeFileSync('tray-icon.png', minimalPNG); + console.log('Created placeholder tray icon.'); +} \ No newline at end of file diff --git a/electron/entitlements.mac.plist b/electron/entitlements.mac.plist new file mode 100644 index 0000000000000000000000000000000000000000..a00aebcd06ae23eefe048e4669ca02f63f961b76 --- /dev/null +++ b/electron/entitlements.mac.plist @@ -0,0 +1,18 @@ + + + + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-jit + + com.apple.security.cs.disable-library-validation + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.network.client + + com.apple.security.network.server + + + \ No newline at end of file diff --git a/electron/icon.png b/electron/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9aab57df06909be712cf63dd6cbb67ae0a88966e --- /dev/null +++ b/electron/icon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8cb3a9463134375c5612f2379e0a8bb624a7ad7b42025ae950edb3bef636b9f +size 31262 diff --git a/electron/main.js b/electron/main.js new file mode 100644 index 0000000000000000000000000000000000000000..210a4565852d6348be03e3d1361bdb2eabcfd559 --- /dev/null +++ b/electron/main.js @@ -0,0 +1,590 @@ +const { app, BrowserWindow, dialog, Tray, Menu, shell } = require('electron'); +const { spawn } = require('child_process'); +const path = require('path'); +const http = require('http'); +const fs = require('fs'); + +let mainWindow; +let serverProcess; +let tray = null; +let serverErrorLogs = []; +const PORT = 3000; +const DEV_FRONTEND_PORT = 5173; // Vite dev server port + +// 保存日志到文件并打开 +function saveAndOpenErrorLog() { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const logFileName = `new-api-crash-${timestamp}.log`; + const logDir = app.getPath('logs'); + const logFilePath = path.join(logDir, logFileName); + + // 确保日志目录存在 + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + + // 写入日志 + const logContent = `New API 崩溃日志 +生成时间: ${new Date().toLocaleString('zh-CN')} +平台: ${process.platform} +架构: ${process.arch} +应用版本: ${app.getVersion()} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +完整错误日志: + +${serverErrorLogs.join('\n')} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +日志文件位置: ${logFilePath} +`; + + fs.writeFileSync(logFilePath, logContent, 'utf8'); + + // 打开日志文件 + shell.openPath(logFilePath).then((error) => { + if (error) { + console.error('Failed to open log file:', error); + // 如果打开文件失败,至少显示文件位置 + shell.showItemInFolder(logFilePath); + } + }); + + return logFilePath; + } catch (err) { + console.error('Failed to save error log:', err); + return null; + } +} + +// 分析错误日志,识别常见错误并提供解决方案 +function analyzeError(errorLogs) { + const allLogs = errorLogs.join('\n'); + + // 检测端口占用错误 + if (allLogs.includes('failed to start HTTP server') || + allLogs.includes('bind: address already in use') || + allLogs.includes('listen tcp') && allLogs.includes('bind: address already in use')) { + return { + type: '端口被占用', + title: '端口 ' + PORT + ' 被占用', + message: '无法启动服务器,端口已被其他程序占用', + solution: `可能的解决方案:\n\n1. 关闭占用端口 ${PORT} 的其他程序\n2. 检查是否已经运行了另一个 New API 实例\n3. 使用以下命令查找占用端口的进程:\n Mac/Linux: lsof -i :${PORT}\n Windows: netstat -ano | findstr :${PORT}\n4. 重启电脑以释放端口` + }; + } + + // 检测数据库错误 + if (allLogs.includes('database is locked') || + allLogs.includes('unable to open database')) { + return { + type: '数据文件被占用', + title: '无法访问数据文件', + message: '应用的数据文件正被其他程序占用', + solution: '可能的解决方案:\n\n1. 检查是否已经打开了另一个 New API 窗口\n - 查看任务栏/Dock 中是否有其他 New API 图标\n - 查看系统托盘(Windows)或菜单栏(Mac)中是否有 New API 图标\n\n2. 如果刚刚关闭过应用,请等待 10 秒后再试\n\n3. 重启电脑以释放被占用的文件\n\n4. 如果问题持续,可以尝试:\n - 退出所有 New API 实例\n - 删除数据目录中的临时文件(.db-shm 和 .db-wal)\n - 重新启动应用' + }; + } + + // 检测权限错误 + if (allLogs.includes('permission denied') || + allLogs.includes('access denied')) { + return { + type: '权限错误', + title: '权限不足', + message: '程序没有足够的权限执行操作', + solution: '可能的解决方案:\n\n1. 以管理员/root权限运行程序\n2. 检查数据目录的读写权限\n3. 检查可执行文件的权限\n4. 在 Mac 上,检查安全性与隐私设置' + }; + } + + // 检测网络错误 + if (allLogs.includes('network is unreachable') || + allLogs.includes('no such host') || + allLogs.includes('connection refused')) { + return { + type: '网络错误', + title: '网络连接失败', + message: '无法建立网络连接', + solution: '可能的解决方案:\n\n1. 检查网络连接是否正常\n2. 检查防火墙设置\n3. 检查代理配置\n4. 确认目标服务器地址正确' + }; + } + + // 检测配置文件错误 + if (allLogs.includes('invalid configuration') || + allLogs.includes('failed to parse config') || + allLogs.includes('yaml') || allLogs.includes('json') && allLogs.includes('parse')) { + return { + type: '配置错误', + title: '配置文件错误', + message: '配置文件格式不正确或包含无效配置', + solution: '可能的解决方案:\n\n1. 检查配置文件格式是否正确\n2. 恢复默认配置\n3. 删除配置文件让程序重新生成\n4. 查看文档了解正确的配置格式' + }; + } + + // 检测内存不足 + if (allLogs.includes('out of memory') || + allLogs.includes('cannot allocate memory')) { + return { + type: '内存不足', + title: '系统内存不足', + message: '程序运行时内存不足', + solution: '可能的解决方案:\n\n1. 关闭其他占用内存的程序\n2. 增加系统可用内存\n3. 重启电脑释放内存\n4. 检查是否存在内存泄漏' + }; + } + + // 检测文件不存在错误 + if (allLogs.includes('no such file or directory') || + allLogs.includes('cannot find the file')) { + return { + type: '文件缺失', + title: '找不到必需的文件', + message: '缺少程序运行所需的文件', + solution: '可能的解决方案:\n\n1. 重新安装应用程序\n2. 检查安装目录是否完整\n3. 确保所有依赖文件都存在\n4. 检查文件路径是否正确' + }; + } + + return null; +} + +function getBinaryPath() { + const isDev = process.env.NODE_ENV === 'development'; + const platform = process.platform; + + if (isDev) { + const binaryName = platform === 'win32' ? 'new-api.exe' : 'new-api'; + return path.join(__dirname, '..', binaryName); + } + + let binaryName; + switch (platform) { + case 'win32': + binaryName = 'new-api.exe'; + break; + case 'darwin': + binaryName = 'new-api'; + break; + case 'linux': + binaryName = 'new-api'; + break; + default: + binaryName = 'new-api'; + } + + return path.join(process.resourcesPath, 'bin', binaryName); +} + +// Check if a server is available with retry logic +function checkServerAvailability(port, maxRetries = 30, retryDelay = 1000) { + return new Promise((resolve, reject) => { + let currentAttempt = 0; + + const tryConnect = () => { + currentAttempt++; + + if (currentAttempt % 5 === 1 && currentAttempt > 1) { + console.log(`Attempting to connect to port ${port}... (attempt ${currentAttempt}/${maxRetries})`); + } + + const req = http.get({ + hostname: '127.0.0.1', // Use IPv4 explicitly instead of 'localhost' to avoid IPv6 issues + port: port, + timeout: 10000 + }, (res) => { + // Server responded, connection successful + req.destroy(); + console.log(`✓ Successfully connected to port ${port} (status: ${res.statusCode})`); + resolve(); + }); + + req.on('error', (err) => { + if (currentAttempt >= maxRetries) { + reject(new Error(`Failed to connect to port ${port} after ${maxRetries} attempts: ${err.message}`)); + } else { + setTimeout(tryConnect, retryDelay); + } + }); + + req.on('timeout', () => { + req.destroy(); + if (currentAttempt >= maxRetries) { + reject(new Error(`Connection timeout on port ${port} after ${maxRetries} attempts`)); + } else { + setTimeout(tryConnect, retryDelay); + } + }); + }; + + tryConnect(); + }); +} + +function startServer() { + return new Promise((resolve, reject) => { + const isDev = process.env.NODE_ENV === 'development'; + + const userDataPath = app.getPath('userData'); + const dataDir = path.join(userDataPath, 'data'); + + // 设置环境变量供 preload.js 使用 + process.env.ELECTRON_DATA_DIR = dataDir; + + if (isDev) { + // 开发模式:假设开发者手动启动了 Go 后端和前端开发服务器 + // 只需要等待前端开发服务器就绪 + console.log('Development mode: skipping server startup'); + console.log('Please make sure you have started:'); + console.log(' 1. Go backend: go run main.go (port 3000)'); + console.log(' 2. Frontend dev server: cd web && bun dev (port 5173)'); + console.log(''); + console.log('Checking if servers are running...'); + + // First check if both servers are accessible + checkServerAvailability(DEV_FRONTEND_PORT) + .then(() => { + console.log('✓ Frontend dev server is accessible on port 5173'); + resolve(); + }) + .catch((err) => { + console.error(`✗ Cannot connect to frontend dev server on port ${DEV_FRONTEND_PORT}`); + console.error('Please make sure the frontend dev server is running:'); + console.error(' cd web && bun dev'); + reject(err); + }); + return; + } + + // 生产模式:启动二进制服务器 + const env = { ...process.env, PORT: PORT.toString() }; + + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + + env.SQLITE_PATH = path.join(dataDir, 'new-api.db'); + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('📁 您的数据存储位置:'); + console.log(' ' + dataDir); + console.log(' 💡 备份提示:复制此目录即可备份所有数据'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + const binaryPath = getBinaryPath(); + const workingDir = process.resourcesPath; + + console.log('Starting server from:', binaryPath); + + serverProcess = spawn(binaryPath, [], { + env, + cwd: workingDir + }); + + serverProcess.stdout.on('data', (data) => { + console.log(`Server: ${data}`); + }); + + serverProcess.stderr.on('data', (data) => { + const errorMsg = data.toString(); + console.error(`Server Error: ${errorMsg}`); + serverErrorLogs.push(errorMsg); + // 只保留最近的100条错误日志 + if (serverErrorLogs.length > 100) { + serverErrorLogs.shift(); + } + }); + + serverProcess.on('error', (err) => { + console.error('Failed to start server:', err); + reject(err); + }); + + serverProcess.on('close', (code) => { + console.log(`Server process exited with code ${code}`); + + // 如果退出代码不是0,说明服务器异常退出 + if (code !== 0 && code !== null) { + const errorDetails = serverErrorLogs.length > 0 + ? serverErrorLogs.slice(-20).join('\n') + : '没有捕获到错误日志'; + + // 分析错误类型 + const knownError = analyzeError(serverErrorLogs); + + let dialogOptions; + if (knownError) { + // 识别到已知错误,显示友好的错误信息和解决方案 + dialogOptions = { + type: 'error', + title: knownError.title, + message: knownError.message, + detail: `${knownError.solution}\n\n━━━━━━━━━━━━━━━━━━━━━━\n\n退出代码: ${code}\n\n错误类型: ${knownError.type}\n\n最近的错误日志:\n${errorDetails}`, + buttons: ['退出应用', '查看完整日志'], + defaultId: 0, + cancelId: 0 + }; + } else { + // 未识别的错误,显示通用错误信息 + dialogOptions = { + type: 'error', + title: '服务器崩溃', + message: '服务器进程异常退出', + detail: `退出代码: ${code}\n\n最近的错误信息:\n${errorDetails}`, + buttons: ['退出应用', '查看完整日志'], + defaultId: 0, + cancelId: 0 + }; + } + + dialog.showMessageBox(dialogOptions).then((result) => { + if (result.response === 1) { + // 用户选择查看详情,保存并打开日志文件 + const logPath = saveAndOpenErrorLog(); + + // 显示确认对话框 + const confirmMessage = logPath + ? `日志已保存到:\n${logPath}\n\n日志文件已在默认文本编辑器中打开。\n\n点击"退出"关闭应用程序。` + : '日志保存失败,但已在控制台输出。\n\n点击"退出"关闭应用程序。'; + + dialog.showMessageBox({ + type: 'info', + title: '日志已保存', + message: confirmMessage, + buttons: ['退出'], + defaultId: 0 + }).then(() => { + app.isQuitting = true; + app.quit(); + }); + + // 同时在控制台输出 + console.log('=== 完整错误日志 ==='); + console.log(serverErrorLogs.join('\n')); + } else { + // 用户选择直接退出 + app.isQuitting = true; + app.quit(); + } + }); + } else { + // 正常退出(code为0或null),直接关闭窗口 + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.close(); + } + } + }); + + checkServerAvailability(PORT) + .then(() => { + console.log('✓ Backend server is accessible on port 3000'); + resolve(); + }) + .catch((err) => { + console.error('✗ Failed to connect to backend server'); + reject(err); + }); + }); +} + +function createWindow() { + const isDev = process.env.NODE_ENV === 'development'; + const loadPort = isDev ? DEV_FRONTEND_PORT : PORT; + + mainWindow = new BrowserWindow({ + width: 1080, + height: 720, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + nodeIntegration: false, + contextIsolation: true + }, + title: 'New API', + icon: path.join(__dirname, 'icon.png') + }); + + mainWindow.loadURL(`http://127.0.0.1:${loadPort}`); + + console.log(`Loading from: http://127.0.0.1:${loadPort}`); + + if (isDev) { + mainWindow.webContents.openDevTools(); + } + + // Close to tray instead of quitting + mainWindow.on('close', (event) => { + if (!app.isQuitting) { + event.preventDefault(); + mainWindow.hide(); + if (process.platform === 'darwin') { + app.dock.hide(); + } + } + }); + + mainWindow.on('closed', () => { + mainWindow = null; + }); +} + +function createTray() { + // Use template icon for macOS (black with transparency, auto-adapts to theme) + // Use colored icon for Windows + const trayIconPath = process.platform === 'darwin' + ? path.join(__dirname, 'tray-iconTemplate.png') + : path.join(__dirname, 'tray-icon-windows.png'); + + tray = new Tray(trayIconPath); + + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Show New API', + click: () => { + if (mainWindow === null) { + createWindow(); + } else { + mainWindow.show(); + if (process.platform === 'darwin') { + app.dock.show(); + } + } + } + }, + { type: 'separator' }, + { + label: 'Quit', + click: () => { + app.isQuitting = true; + app.quit(); + } + } + ]); + + tray.setToolTip('New API'); + tray.setContextMenu(contextMenu); + + // On macOS, clicking the tray icon shows the window + tray.on('click', () => { + if (mainWindow === null) { + createWindow(); + } else { + mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show(); + if (mainWindow.isVisible() && process.platform === 'darwin') { + app.dock.show(); + } + } + }); +} + +app.whenReady().then(async () => { + try { + await startServer(); + createTray(); + createWindow(); + } catch (err) { + console.error('Failed to start application:', err); + + // 分析启动失败的错误 + const knownError = analyzeError(serverErrorLogs); + + if (knownError) { + dialog.showMessageBox({ + type: 'error', + title: knownError.title, + message: `启动失败: ${knownError.message}`, + detail: `${knownError.solution}\n\n━━━━━━━━━━━━━━━━━━━━━━\n\n错误信息: ${err.message}\n\n错误类型: ${knownError.type}`, + buttons: ['退出', '查看完整日志'], + defaultId: 0, + cancelId: 0 + }).then((result) => { + if (result.response === 1) { + // 用户选择查看日志 + const logPath = saveAndOpenErrorLog(); + + const confirmMessage = logPath + ? `日志已保存到:\n${logPath}\n\n日志文件已在默认文本编辑器中打开。\n\n点击"退出"关闭应用程序。` + : '日志保存失败,但已在控制台输出。\n\n点击"退出"关闭应用程序。'; + + dialog.showMessageBox({ + type: 'info', + title: '日志已保存', + message: confirmMessage, + buttons: ['退出'], + defaultId: 0 + }).then(() => { + app.quit(); + }); + + console.log('=== 完整错误日志 ==='); + console.log(serverErrorLogs.join('\n')); + } else { + app.quit(); + } + }); + } else { + dialog.showMessageBox({ + type: 'error', + title: '启动失败', + message: '无法启动服务器', + detail: `错误信息: ${err.message}\n\n请检查日志获取更多信息。`, + buttons: ['退出', '查看完整日志'], + defaultId: 0, + cancelId: 0 + }).then((result) => { + if (result.response === 1) { + // 用户选择查看日志 + const logPath = saveAndOpenErrorLog(); + + const confirmMessage = logPath + ? `日志已保存到:\n${logPath}\n\n日志文件已在默认文本编辑器中打开。\n\n点击"退出"关闭应用程序。` + : '日志保存失败,但已在控制台输出。\n\n点击"退出"关闭应用程序。'; + + dialog.showMessageBox({ + type: 'info', + title: '日志已保存', + message: confirmMessage, + buttons: ['退出'], + defaultId: 0 + }).then(() => { + app.quit(); + }); + + console.log('=== 完整错误日志 ==='); + console.log(serverErrorLogs.join('\n')); + } else { + app.quit(); + } + }); + } + } +}); + +app.on('window-all-closed', () => { + // Don't quit when window is closed, keep running in tray + // Only quit when explicitly choosing Quit from tray menu +}); + +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } +}); + +app.on('before-quit', (event) => { + if (serverProcess) { + event.preventDefault(); + + console.log('Shutting down server...'); + serverProcess.kill('SIGTERM'); + + setTimeout(() => { + if (serverProcess) { + serverProcess.kill('SIGKILL'); + } + app.exit(); + }, 5000); + + serverProcess.on('close', () => { + serverProcess = null; + app.exit(); + }); + } +}); \ No newline at end of file diff --git a/electron/package-lock.json b/electron/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..00aed6d47545c208617d6ae70b0adbfd53bc5d9c --- /dev/null +++ b/electron/package-lock.json @@ -0,0 +1,4117 @@ +{ + "name": "new-api-electron", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "new-api-electron", + "version": "1.0.0", + "devDependencies": { + "cross-env": "^7.0.3", + "electron": "35.7.5", + "electron-builder": "^24.9.1" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.2.1.tgz", + "integrity": "sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz", + "integrity": "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/universal": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.5.1.tgz", + "integrity": "sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.1", + "@malept/cross-spawn-promise": "^1.1.0", + "debug": "^4.3.1", + "dir-compare": "^3.0.0", + "fs-extra": "^9.0.1", + "minimatch": "^3.0.4", + "plist": "^3.0.4" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.18.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.8.tgz", + "integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", + "integrity": "sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.13.3.tgz", + "integrity": "sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.2.1", + "@electron/osx-sign": "1.0.5", + "@electron/universal": "1.5.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chromium-pickle-js": "^0.2.0", + "debug": "^4.3.4", + "ejs": "^3.1.8", + "electron-publish": "24.13.1", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "minimatch": "^5.1.1", + "read-config-file": "6.3.2", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "24.13.3", + "electron-builder-squirrel-windows": "24.13.3" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", + "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-24.13.1.tgz", + "integrity": "sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "4.0.0", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.4.tgz", + "integrity": "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.6.tgz", + "integrity": "sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.10", + "typescript": "^5.3.3" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", + "integrity": "sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal": "^1.0.0", + "minimatch": "^3.0.4" + } + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz", + "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", + "integrity": "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "35.7.5", + "resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz", + "integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^22.7.7", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.13.3.tgz", + "integrity": "sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "dmg-builder": "24.13.3", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "read-config-file": "6.3.2", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-24.13.3.tgz", + "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "24.13.3", + "archiver": "^5.3.1", + "builder-util": "24.13.1", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz", + "integrity": "sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/isbinaryfile": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.6.tgz", + "integrity": "sha512-I+NmIfBHUl+r2wcDd6JwE9yWje/PIVY/R5/CmV8dXLZd5K+L9X2klAOwfAHNnondLXkbHyTAleQAWonpTJBTtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-config-file": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.3.2.tgz", + "integrity": "sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-file-ts": "^0.2.4", + "dotenv": "^9.0.2", + "dotenv-expand": "^5.1.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.0", + "lazy-val": "^1.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "dev": true, + "license": "ISC" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 0000000000000000000000000000000000000000..f861c91cb43612a21bc249dd731ca9769a833fb2 --- /dev/null +++ b/electron/package.json @@ -0,0 +1,101 @@ +{ + "name": "new-api-electron", + "version": "1.0.0", + "description": "New API - AI Model Gateway Desktop Application", + "main": "main.js", + "scripts": { + "start-app": "electron .", + "dev-app": "cross-env NODE_ENV=development electron .", + "build": "electron-builder", + "build:mac": "electron-builder --mac", + "build:win": "electron-builder --win", + "build:linux": "electron-builder --linux" + }, + "keywords": [ + "ai", + "api", + "gateway", + "openai", + "claude" + ], + "author": "QuantumNous", + "repository": { + "type": "git", + "url": "https://github.com/QuantumNous/new-api" + }, + "devDependencies": { + "cross-env": "^7.0.3", + "electron": "35.7.5", + "electron-builder": "^24.9.1" + }, + "build": { + "appId": "com.newapi.desktop", + "productName": "New-API-App", + "publish": null, + "directories": { + "output": "dist" + }, + "files": [ + "main.js", + "preload.js", + "icon.png", + "tray-iconTemplate.png", + "tray-iconTemplate@2x.png", + "tray-icon-windows.png" + ], + "mac": { + "category": "public.app-category.developer-tools", + "icon": "icon.png", + "identity": null, + "hardenedRuntime": false, + "gatekeeperAssess": false, + "entitlements": "entitlements.mac.plist", + "entitlementsInherit": "entitlements.mac.plist", + "target": [ + "dmg", + "zip" + ], + "extraResources": [ + { + "from": "../new-api", + "to": "bin/new-api" + }, + { + "from": "../web/dist", + "to": "web/dist" + } + ] + }, + "win": { + "icon": "icon.png", + "target": [ + "nsis", + "portable" + ], + "extraResources": [ + { + "from": "../new-api.exe", + "to": "bin/new-api.exe" + } + ] + }, + "linux": { + "icon": "icon.png", + "target": [ + "AppImage", + "deb" + ], + "category": "Development", + "extraResources": [ + { + "from": "../new-api", + "to": "bin/new-api" + } + ] + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true + } + } +} \ No newline at end of file diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 0000000000000000000000000000000000000000..ac971fd0a1c46abf5273387ae34fb2da93ad7926 --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,18 @@ +const { contextBridge } = require('electron'); + +// 获取数据目录路径(用于显示给用户) +// 优先使用主进程设置的真实路径,如果没有则回退到手动拼接 +function getDataDirPath() { + // 如果主进程已设置真实路径,直接使用 + if (process.env.ELECTRON_DATA_DIR) { + return process.env.ELECTRON_DATA_DIR; + } +} + +contextBridge.exposeInMainWorld('electron', { + isElectron: true, + version: process.versions.electron, + platform: process.platform, + versions: process.versions, + dataDir: getDataDirPath() +}); \ No newline at end of file diff --git a/electron/tray-icon-windows.png b/electron/tray-icon-windows.png new file mode 100644 index 0000000000000000000000000000000000000000..30e0fe73d51492d96f4b57c771a3ad48c9d771c7 --- /dev/null +++ b/electron/tray-icon-windows.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a86062f7627b00e0c4e702b3afa7211a502d94bc0c107de6aad8638009506ce +size 1203 diff --git a/electron/tray-iconTemplate.png b/electron/tray-iconTemplate.png new file mode 100644 index 0000000000000000000000000000000000000000..890a3079352fcf5e93f7dbbd64c43ecf01db6baf --- /dev/null +++ b/electron/tray-iconTemplate.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bf1a8f48568d02de0804808ead1858503b85498fd5e571e52f8f1dfecf69b31 +size 459 diff --git a/electron/tray-iconTemplate@2x.png b/electron/tray-iconTemplate@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..59a5792578bac766dcdb5a79422e78d64a998c46 --- /dev/null +++ b/electron/tray-iconTemplate@2x.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9db7278214b49cb15b5e5f6d01c40f8aab0f00ca703a1ca6fae010a13df281de +size 754 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..ff03c03d3bafad4c05e988677aa659252ae942ac --- /dev/null +++ b/go.mod @@ -0,0 +1,122 @@ +module github.com/QuantumNous/new-api + +// +heroku goVersion go1.18 +go 1.25.1 + +require ( + github.com/Calcium-Ion/go-epay v0.0.4 + github.com/abema/go-mp4 v1.4.1 + github.com/andybalholm/brotli v1.1.1 + github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 + github.com/aws/aws-sdk-go-v2 v1.37.2 + github.com/aws/aws-sdk-go-v2/credentials v1.17.11 + github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 + github.com/aws/smithy-go v1.22.5 + github.com/bytedance/gopkg v0.1.3 + github.com/gin-contrib/cors v1.7.2 + github.com/gin-contrib/gzip v0.0.6 + github.com/gin-contrib/sessions v0.0.5 + github.com/gin-contrib/static v0.0.1 + github.com/gin-gonic/gin v1.9.1 + github.com/glebarez/sqlite v1.9.0 + github.com/go-audio/aiff v1.1.0 + github.com/go-audio/wav v1.1.0 + github.com/go-playground/validator/v10 v10.20.0 + github.com/go-redis/redis/v8 v8.11.5 + github.com/go-webauthn/webauthn v0.14.0 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.0 + github.com/jfreymuth/oggvorbis v1.0.5 + github.com/jinzhu/copier v0.4.0 + github.com/joho/godotenv v1.5.1 + github.com/mewkiz/flac v1.0.13 + github.com/pkg/errors v0.9.1 + github.com/pquerna/otp v1.5.0 + github.com/samber/lo v1.39.0 + github.com/shirou/gopsutil v3.21.11+incompatible + github.com/shopspring/decimal v1.4.0 + github.com/stripe/stripe-go/v81 v81.4.0 + github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 + github.com/thanhpk/randstr v1.0.6 + github.com/tidwall/gjson v1.18.0 + github.com/tidwall/sjson v1.2.5 + github.com/tiktoken-go/tokenizer v0.6.2 + github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c + golang.org/x/crypto v0.45.0 + golang.org/x/image v0.23.0 + golang.org/x/net v0.47.0 + golang.org/x/sync v0.18.0 + gorm.io/driver/mysql v1.4.3 + gorm.io/driver/postgres v1.5.2 + gorm.io/gorm v1.25.2 +) + +require ( + github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect + github.com/boombuler/barcode v1.1.0 // indirect + github.com/bytedance/sonic v1.14.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-audio/audio v1.0.0 // indirect + github.com/go-audio/riff v1.0.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/go-webauthn/x v0.1.25 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-tpm v0.9.5 // indirect + github.com/gorilla/context v1.1.1 // indirect + github.com/gorilla/securecookie v1.1.1 // indirect + github.com/gorilla/sessions v1.2.1 // indirect + github.com/icza/bitio v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jfreymuth/vorbis v1.0.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect + github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect + golang.org/x/arch v0.21.0 // indirect + golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..f43717973318547444d800545ba5b73d5c3004b1 --- /dev/null +++ b/go.sum @@ -0,0 +1,353 @@ +github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A= +github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U= +github.com/abema/go-mp4 v1.4.1 h1:YoS4VRqd+pAmddRPLFf8vMk74kuGl6ULSjzhsIqwr6M= +github.com/abema/go-mp4 v1.4.1/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs= +github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI= +github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI= +github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8= +github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo= +github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs= +github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 h1:sPiRHLVUIIQcoVZTNwqQcdtjkqkPopyYmIX0M5ElRf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2/go.mod h1:ik86P3sgV+Bk7c1tBFCwI3VxMoSEwl4YkRB9xn1s340= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 h1:ZdzDAg075H6stMZtbD2o+PyB933M/f20e9WmCBC17wA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2/go.mod h1:eE1IIzXG9sdZCB0pNNpMpsYTLl4YdOQD3njiVN1e/E4= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 h1:JzidOz4Hcn2RbP5fvIS1iAP+DcRv5VJtgixbEYDsI5g= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0/go.mod h1:9A4/PJYlWjvjEzzoOLGQjkLt4bYK9fRWi7uz1GSsAcA= +github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= +github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= +github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= +github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE= +github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/static v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Swm1U= +github.com/gin-contrib/static v0.0.1/go.mod h1:CSxeF+wep05e0kCOsqWdAWbSszmc31zTIbD8TvWl7Hs= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs= +github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw= +github.com/go-audio/aiff v1.1.0 h1:m2LYgu/2BarpF2yZnFPWtY3Tp41k0A4y51gDRZZsEuU= +github.com/go-audio/aiff v1.1.0/go.mod h1:sDik1muYvhPiccClfri0fv6U2fyH/dy4VRWmUz0cz9Q= +github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4= +github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= +github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA= +github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= +github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE= +github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g= +github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0= +github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k= +github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88= +github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY= +github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= +github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= +github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ= +github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= +github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE= +github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= +github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= +github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattetti/audio v0.0.0-20180912171649-01576cde1f21/go.mod h1:LlQmBGkOuV/SKzEDXBPKauvN2UqCgzXO2XjecTGj40s= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs= +github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k= +github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU= +github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI= +github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8= +github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw= +github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0= +github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= +github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg= +github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= +github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw= +github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo= +github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I= +github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vAjVY4qbSr8rQ610YzTkWcxzxSI= +github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI= +github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o= +github.com/thanhpk/randstr v1.0.6/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g= +github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivzaZIrDcwvpJ3Fnpsk5fDOJ4iSnL6J0w= +github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= +golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= +golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= +gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.4.3 h1:/JhWJhO2v17d8hjApTltKNADm7K7YI2ogkR7avJUL3k= +gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c= +gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0= +gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8= +gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= +gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho= +gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000000000000000000000000000000000000..61b1d49d8e6b43761cc742766e3b4622e0940e9c --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,160 @@ +package logger + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "os" + "path/filepath" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/operation_setting" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-gonic/gin" +) + +const ( + loggerINFO = "INFO" + loggerWarn = "WARN" + loggerError = "ERR" + loggerDebug = "DEBUG" +) + +const maxLogCount = 1000000 + +var logCount int +var setupLogLock sync.Mutex +var setupLogWorking bool + +func SetupLogger() { + defer func() { + setupLogWorking = false + }() + if *common.LogDir != "" { + ok := setupLogLock.TryLock() + if !ok { + log.Println("setup log is already working") + return + } + defer func() { + setupLogLock.Unlock() + }() + logPath := filepath.Join(*common.LogDir, fmt.Sprintf("oneapi-%s.log", time.Now().Format("20060102150405"))) + fd, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatal("failed to open log file") + } + gin.DefaultWriter = io.MultiWriter(os.Stdout, fd) + gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, fd) + } +} + +func LogInfo(ctx context.Context, msg string) { + logHelper(ctx, loggerINFO, msg) +} + +func LogWarn(ctx context.Context, msg string) { + logHelper(ctx, loggerWarn, msg) +} + +func LogError(ctx context.Context, msg string) { + logHelper(ctx, loggerError, msg) +} + +func LogDebug(ctx context.Context, msg string, args ...any) { + if common.DebugEnabled { + if len(args) > 0 { + msg = fmt.Sprintf(msg, args...) + } + logHelper(ctx, loggerDebug, msg) + } +} + +func logHelper(ctx context.Context, level string, msg string) { + writer := gin.DefaultErrorWriter + if level == loggerINFO { + writer = gin.DefaultWriter + } + id := ctx.Value(common.RequestIdKey) + if id == nil { + id = "SYSTEM" + } + now := time.Now() + _, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg) + logCount++ // we don't need accurate count, so no lock here + if logCount > maxLogCount && !setupLogWorking { + logCount = 0 + setupLogWorking = true + gopool.Go(func() { + SetupLogger() + }) + } +} + +func LogQuota(quota int) string { + // 新逻辑:根据额度展示类型输出 + q := float64(quota) + switch operation_setting.GetQuotaDisplayType() { + case operation_setting.QuotaDisplayTypeCNY: + usd := q / common.QuotaPerUnit + cny := usd * operation_setting.USDExchangeRate + return fmt.Sprintf("¥%.6f 额度", cny) + case operation_setting.QuotaDisplayTypeCustom: + usd := q / common.QuotaPerUnit + rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate + symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol + if symbol == "" { + symbol = "¤" + } + if rate <= 0 { + rate = 1 + } + v := usd * rate + return fmt.Sprintf("%s%.6f 额度", symbol, v) + case operation_setting.QuotaDisplayTypeTokens: + return fmt.Sprintf("%d 点额度", quota) + default: // USD + return fmt.Sprintf("$%.6f 额度", q/common.QuotaPerUnit) + } +} + +func FormatQuota(quota int) string { + q := float64(quota) + switch operation_setting.GetQuotaDisplayType() { + case operation_setting.QuotaDisplayTypeCNY: + usd := q / common.QuotaPerUnit + cny := usd * operation_setting.USDExchangeRate + return fmt.Sprintf("¥%.6f", cny) + case operation_setting.QuotaDisplayTypeCustom: + usd := q / common.QuotaPerUnit + rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate + symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol + if symbol == "" { + symbol = "¤" + } + if rate <= 0 { + rate = 1 + } + v := usd * rate + return fmt.Sprintf("%s%.6f", symbol, v) + case operation_setting.QuotaDisplayTypeTokens: + return fmt.Sprintf("%d", quota) + default: + return fmt.Sprintf("$%.6f", q/common.QuotaPerUnit) + } +} + +// LogJson 仅供测试使用 only for test +func LogJson(ctx context.Context, msg string, obj any) { + jsonStr, err := json.Marshal(obj) + if err != nil { + LogError(ctx, fmt.Sprintf("json marshal failed: %s", err.Error())) + return + } + LogDebug(ctx, fmt.Sprintf("%s | %s", msg, string(jsonStr))) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000000000000000000000000000000000000..481d0a6002eb8b4b2ef08f375ddda08ce106965f --- /dev/null +++ b/main.go @@ -0,0 +1,260 @@ +package main + +import ( + "bytes" + "embed" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/router" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/ratio_setting" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" + + _ "net/http/pprof" +) + +//go:embed web/dist +var buildFS embed.FS + +//go:embed web/dist/index.html +var indexPage []byte + +func main() { + startTime := time.Now() + + err := InitResources() + if err != nil { + common.FatalLog("failed to initialize resources: " + err.Error()) + return + } + + common.SysLog("New API " + common.Version + " started") + if os.Getenv("GIN_MODE") != "debug" { + gin.SetMode(gin.ReleaseMode) + } + if common.DebugEnabled { + common.SysLog("running in debug mode") + } + + defer func() { + err := model.CloseDB() + if err != nil { + common.FatalLog("failed to close database: " + err.Error()) + } + }() + + if common.RedisEnabled { + // for compatibility with old versions + common.MemoryCacheEnabled = true + } + if common.MemoryCacheEnabled { + common.SysLog("memory cache enabled") + common.SysLog(fmt.Sprintf("sync frequency: %d seconds", common.SyncFrequency)) + + // Add panic recovery and retry for InitChannelCache + func() { + defer func() { + if r := recover(); r != nil { + common.SysLog(fmt.Sprintf("InitChannelCache panic: %v, retrying once", r)) + // Retry once + _, _, fixErr := model.FixAbility() + if fixErr != nil { + common.FatalLog(fmt.Sprintf("InitChannelCache failed: %s", fixErr.Error())) + } + } + }() + model.InitChannelCache() + }() + + go model.SyncChannelCache(common.SyncFrequency) + } + + // 热更新配置 + go model.SyncOptions(common.SyncFrequency) + + // 数据看板 + go model.UpdateQuotaData() + + if os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" { + frequency, err := strconv.Atoi(os.Getenv("CHANNEL_UPDATE_FREQUENCY")) + if err != nil { + common.FatalLog("failed to parse CHANNEL_UPDATE_FREQUENCY: " + err.Error()) + } + go controller.AutomaticallyUpdateChannels(frequency) + } + + go controller.AutomaticallyTestChannels() + + if common.IsMasterNode && constant.UpdateTask { + gopool.Go(func() { + controller.UpdateMidjourneyTaskBulk() + }) + gopool.Go(func() { + controller.UpdateTaskBulk() + }) + } + if os.Getenv("BATCH_UPDATE_ENABLED") == "true" { + common.BatchUpdateEnabled = true + common.SysLog("batch update enabled with interval " + strconv.Itoa(common.BatchUpdateInterval) + "s") + model.InitBatchUpdater() + } + + if os.Getenv("ENABLE_PPROF") == "true" { + gopool.Go(func() { + log.Println(http.ListenAndServe("0.0.0.0:8005", nil)) + }) + go common.Monitor() + common.SysLog("pprof enabled") + } + + // Initialize HTTP server + server := gin.New() + server.Use(gin.CustomRecovery(func(c *gin.Context, err any) { + common.SysLog(fmt.Sprintf("panic detected: %v", err)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err), + "type": "new_api_panic", + }, + }) + })) + // This will cause SSE not to work!!! + //server.Use(gzip.Gzip(gzip.DefaultCompression)) + server.Use(middleware.RequestId()) + middleware.SetUpLogger(server) + // Initialize session store + store := cookie.NewStore([]byte(common.SessionSecret)) + store.Options(sessions.Options{ + Path: "/", + MaxAge: 2592000, // 30 days + HttpOnly: true, + Secure: false, + SameSite: http.SameSiteStrictMode, + }) + server.Use(sessions.Sessions("session", store)) + + InjectUmamiAnalytics() + InjectGoogleAnalytics() + + // 设置路由 + router.SetRouter(server, buildFS, indexPage) + var port = os.Getenv("PORT") + if port == "" { + port = strconv.Itoa(*common.Port) + } + + // Log startup success message + common.LogStartupSuccess(startTime, port) + + err = server.Run(":" + port) + if err != nil { + common.FatalLog("failed to start HTTP server: " + err.Error()) + } +} + +func InjectUmamiAnalytics() { + analyticsInjectBuilder := &strings.Builder{} + if os.Getenv("UMAMI_WEBSITE_ID") != "" { + umamiSiteID := os.Getenv("UMAMI_WEBSITE_ID") + umamiScriptURL := os.Getenv("UMAMI_SCRIPT_URL") + if umamiScriptURL == "" { + umamiScriptURL = "https://analytics.umami.is/script.js" + } + analyticsInjectBuilder.WriteString("") + } + analyticsInject := analyticsInjectBuilder.String() + indexPage = bytes.ReplaceAll(indexPage, []byte("\n"), []byte(analyticsInject)) +} + +func InjectGoogleAnalytics() { + analyticsInjectBuilder := &strings.Builder{} + if os.Getenv("GOOGLE_ANALYTICS_ID") != "" { + gaID := os.Getenv("GOOGLE_ANALYTICS_ID") + // Google Analytics 4 (gtag.js) + analyticsInjectBuilder.WriteString("") + analyticsInjectBuilder.WriteString("") + } + analyticsInject := analyticsInjectBuilder.String() + indexPage = bytes.ReplaceAll(indexPage, []byte("\n"), []byte(analyticsInject)) +} + +func InitResources() error { + // Initialize resources here if needed + // This is a placeholder function for future resource initialization + err := godotenv.Load(".env") + if err != nil { + if common.DebugEnabled { + common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.") + } + } + + // 加载环境变量 + common.InitEnv() + + logger.SetupLogger() + + // Initialize model settings + ratio_setting.InitRatioSettings() + + service.InitHttpClient() + + service.InitTokenEncoders() + + // Initialize SQL Database + err = model.InitDB() + if err != nil { + common.FatalLog("failed to initialize database: " + err.Error()) + return err + } + + model.CheckSetup() + + // Initialize options, should after model.InitDB() + model.InitOptionMap() + + // 初始化模型 + model.GetPricing() + + // Initialize SQL Database + err = model.InitLogDB() + if err != nil { + return err + } + + // Initialize Redis + err = common.InitRedisClient() + if err != nil { + return err + } + return nil +} diff --git a/makefile b/makefile new file mode 100644 index 0000000000000000000000000000000000000000..cbc4ea6ae22de97d930d7b800e04d316ae140bcc --- /dev/null +++ b/makefile @@ -0,0 +1,14 @@ +FRONTEND_DIR = ./web +BACKEND_DIR = . + +.PHONY: all build-frontend start-backend + +all: build-frontend start-backend + +build-frontend: + @echo "Building frontend..." + @cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build + +start-backend: + @echo "Starting backend dev server..." + @cd $(BACKEND_DIR) && go run main.go & diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000000000000000000000000000000000000..dc59df9afffde3eb1015a4d153cf760c61e15bf9 --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,320 @@ +package middleware + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/ratio_setting" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +func validUserInfo(username string, role int) bool { + // check username is empty + if strings.TrimSpace(username) == "" { + return false + } + if !common.IsValidateRole(role) { + return false + } + return true +} + +func authHelper(c *gin.Context, minRole int) { + session := sessions.Default(c) + username := session.Get("username") + role := session.Get("role") + id := session.Get("id") + status := session.Get("status") + useAccessToken := false + if username == nil { + // Check access token + accessToken := c.Request.Header.Get("Authorization") + if accessToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "无权进行此操作,未登录且未提供 access token", + }) + c.Abort() + return + } + user := model.ValidateAccessToken(accessToken) + if user != nil && user.Username != "" { + if !validUserInfo(user.Username, user.Role) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权进行此操作,用户信息无效", + }) + c.Abort() + return + } + // Token is valid + username = user.Username + role = user.Role + id = user.Id + status = user.Status + useAccessToken = true + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权进行此操作,access token 无效", + }) + c.Abort() + return + } + } + // get header New-Api-User + apiUserIdStr := c.Request.Header.Get("New-Api-User") + if apiUserIdStr == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "无权进行此操作,未提供 New-Api-User", + }) + c.Abort() + return + } + apiUserId, err := strconv.Atoi(apiUserIdStr) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "无权进行此操作,New-Api-User 格式错误", + }) + c.Abort() + return + + } + if id != apiUserId { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "无权进行此操作,New-Api-User 与登录用户不匹配", + }) + c.Abort() + return + } + if status.(int) == common.UserStatusDisabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已被封禁", + }) + c.Abort() + return + } + if role.(int) < minRole { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权进行此操作,权限不足", + }) + c.Abort() + return + } + if !validUserInfo(username.(string), role.(int)) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权进行此操作,用户信息无效", + }) + c.Abort() + return + } + c.Set("username", username) + c.Set("role", role) + c.Set("id", id) + c.Set("group", session.Get("group")) + c.Set("user_group", session.Get("group")) + c.Set("use_access_token", useAccessToken) + + //userCache, err := model.GetUserCache(id.(int)) + //if err != nil { + // c.JSON(http.StatusOK, gin.H{ + // "success": false, + // "message": err.Error(), + // }) + // c.Abort() + // return + //} + //userCache.WriteContext(c) + + c.Next() +} + +func TryUserAuth() func(c *gin.Context) { + return func(c *gin.Context) { + session := sessions.Default(c) + id := session.Get("id") + if id != nil { + c.Set("id", id) + } + c.Next() + } +} + +func UserAuth() func(c *gin.Context) { + return func(c *gin.Context) { + authHelper(c, common.RoleCommonUser) + } +} + +func AdminAuth() func(c *gin.Context) { + return func(c *gin.Context) { + authHelper(c, common.RoleAdminUser) + } +} + +func RootAuth() func(c *gin.Context) { + return func(c *gin.Context) { + authHelper(c, common.RoleRootUser) + } +} + +func WssAuth(c *gin.Context) { + +} + +func TokenAuth() func(c *gin.Context) { + return func(c *gin.Context) { + // 先检测是否为ws + if c.Request.Header.Get("Sec-WebSocket-Protocol") != "" { + // Sec-WebSocket-Protocol: realtime, openai-insecure-api-key.sk-xxx, openai-beta.realtime-v1 + // read sk from Sec-WebSocket-Protocol + key := c.Request.Header.Get("Sec-WebSocket-Protocol") + parts := strings.Split(key, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "openai-insecure-api-key") { + key = strings.TrimPrefix(part, "openai-insecure-api-key.") + break + } + } + c.Request.Header.Set("Authorization", "Bearer "+key) + } + // 检查path包含/v1/messages + if strings.Contains(c.Request.URL.Path, "/v1/messages") { + anthropicKey := c.Request.Header.Get("x-api-key") + if anthropicKey != "" { + c.Request.Header.Set("Authorization", "Bearer "+anthropicKey) + } + } + // gemini api 从query中获取key + if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models") || + strings.HasPrefix(c.Request.URL.Path, "/v1beta/openai/models") || + strings.HasPrefix(c.Request.URL.Path, "/v1/models/") { + skKey := c.Query("key") + if skKey != "" { + c.Request.Header.Set("Authorization", "Bearer "+skKey) + } + // 从x-goog-api-key header中获取key + xGoogKey := c.Request.Header.Get("x-goog-api-key") + if xGoogKey != "" { + c.Request.Header.Set("Authorization", "Bearer "+xGoogKey) + } + } + key := c.Request.Header.Get("Authorization") + parts := make([]string, 0) + key = strings.TrimPrefix(key, "Bearer ") + if key == "" || key == "midjourney-proxy" { + key = c.Request.Header.Get("mj-api-secret") + key = strings.TrimPrefix(key, "Bearer ") + key = strings.TrimPrefix(key, "sk-") + parts = strings.Split(key, "-") + key = parts[0] + } else { + key = strings.TrimPrefix(key, "sk-") + parts = strings.Split(key, "-") + key = parts[0] + } + token, err := model.ValidateUserToken(key) + if token != nil { + id := c.GetInt("id") + if id == 0 { + c.Set("id", token.UserId) + } + } + if err != nil { + abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error()) + return + } + + allowIpsMap := token.GetIpLimitsMap() + if len(allowIpsMap) != 0 { + clientIp := c.ClientIP() + if _, ok := allowIpsMap[clientIp]; !ok { + abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中") + return + } + } + + userCache, err := model.GetUserCache(token.UserId) + if err != nil { + abortWithOpenAiMessage(c, http.StatusInternalServerError, err.Error()) + return + } + userEnabled := userCache.Status == common.UserStatusEnabled + if !userEnabled { + abortWithOpenAiMessage(c, http.StatusForbidden, "用户已被封禁") + return + } + + userCache.WriteContext(c) + + userGroup := userCache.Group + tokenGroup := token.Group + if tokenGroup != "" { + // check common.UserUsableGroups[userGroup] + if _, ok := service.GetUserUsableGroups(userGroup)[tokenGroup]; !ok { + abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("无权访问 %s 分组", tokenGroup)) + return + } + // check group in common.GroupRatio + if !ratio_setting.ContainsGroupRatio(tokenGroup) { + if tokenGroup != "auto" { + abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup)) + return + } + } + userGroup = tokenGroup + } + common.SetContextKey(c, constant.ContextKeyUsingGroup, userGroup) + + err = SetupContextForToken(c, token, parts...) + if err != nil { + return + } + c.Next() + } +} + +func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) error { + if token == nil { + return fmt.Errorf("token is nil") + } + c.Set("id", token.UserId) + c.Set("token_id", token.Id) + c.Set("token_key", token.Key) + c.Set("token_name", token.Name) + c.Set("token_unlimited_quota", token.UnlimitedQuota) + if !token.UnlimitedQuota { + c.Set("token_quota", token.RemainQuota) + } + if token.ModelLimitsEnabled { + c.Set("token_model_limit_enabled", true) + c.Set("token_model_limit", token.GetModelLimitsMap()) + } else { + c.Set("token_model_limit_enabled", false) + } + c.Set("token_group", token.Group) + if len(parts) > 1 { + if model.IsAdmin(token.UserId) { + c.Set("specific_channel_id", parts[1]) + } else { + abortWithOpenAiMessage(c, http.StatusForbidden, "普通用户不支持指定渠道") + return fmt.Errorf("普通用户不支持指定渠道") + } + } + return nil +} diff --git a/middleware/cache.go b/middleware/cache.go new file mode 100644 index 0000000000000000000000000000000000000000..979734ab2fb2817c1274fc934181001a2d5dd375 --- /dev/null +++ b/middleware/cache.go @@ -0,0 +1,16 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +func Cache() func(c *gin.Context) { + return func(c *gin.Context) { + if c.Request.RequestURI == "/" { + c.Header("Cache-Control", "no-cache") + } else { + c.Header("Cache-Control", "max-age=604800") // one week + } + c.Next() + } +} diff --git a/middleware/cors.go b/middleware/cors.go new file mode 100644 index 0000000000000000000000000000000000000000..d2a109abece64ceb4268dcefa1a5dcb7dcf85e67 --- /dev/null +++ b/middleware/cors.go @@ -0,0 +1,15 @@ +package middleware + +import ( + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func CORS() gin.HandlerFunc { + config := cors.DefaultConfig() + config.AllowAllOrigins = true + config.AllowCredentials = true + config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} + config.AllowHeaders = []string{"*"} + return cors.New(config) +} diff --git a/middleware/disable-cache.go b/middleware/disable-cache.go new file mode 100644 index 0000000000000000000000000000000000000000..3076e90a89c04c19952b73df81d20566f9743e3f --- /dev/null +++ b/middleware/disable-cache.go @@ -0,0 +1,12 @@ +package middleware + +import "github.com/gin-gonic/gin" + +func DisableCache() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Cache-Control", "no-store, no-cache, must-revalidate, private, max-age=0") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") + c.Next() + } +} diff --git a/middleware/distributor.go b/middleware/distributor.go new file mode 100644 index 0000000000000000000000000000000000000000..5a9deb23cde89ba408f1fdd73a9f46f6d5aeaaaf --- /dev/null +++ b/middleware/distributor.go @@ -0,0 +1,382 @@ +package middleware + +import ( + "errors" + "fmt" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type ModelRequest struct { + Model string `json:"model"` + Group string `json:"group,omitempty"` +} + +func Distribute() func(c *gin.Context) { + return func(c *gin.Context) { + var channel *model.Channel + channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId) + modelRequest, shouldSelectChannel, err := getModelRequest(c) + if err != nil { + abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid request, "+err.Error()) + return + } + if ok { + id, err := strconv.Atoi(channelId.(string)) + if err != nil { + abortWithOpenAiMessage(c, http.StatusBadRequest, "无效的渠道 Id") + return + } + channel, err = model.GetChannelById(id, true) + if err != nil { + abortWithOpenAiMessage(c, http.StatusBadRequest, "无效的渠道 Id") + return + } + if channel.Status != common.ChannelStatusEnabled { + abortWithOpenAiMessage(c, http.StatusForbidden, "该渠道已被禁用") + return + } + } else { + // Select a channel for the user + // check token model mapping + modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) + if modelLimitEnable { + s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit) + if !ok { + // token model limit is empty, all models are not allowed + abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问任何模型") + return + } + var tokenModelLimit map[string]bool + tokenModelLimit, ok = s.(map[string]bool) + if !ok { + tokenModelLimit = map[string]bool{} + } + matchName := ratio_setting.FormatMatchingModelName(modelRequest.Model) // match gpts & thinking-* + if _, ok := tokenModelLimit[matchName]; !ok { + abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问模型 "+modelRequest.Model) + return + } + } + + if shouldSelectChannel { + if modelRequest.Model == "" { + abortWithOpenAiMessage(c, http.StatusBadRequest, "未指定模型名称,模型名称不能为空") + return + } + var selectGroup string + usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) + // check path is /pg/chat/completions + if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") { + playgroundRequest := &dto.PlayGroundRequest{} + err = common.UnmarshalBodyReusable(c, playgroundRequest) + if err != nil { + abortWithOpenAiMessage(c, http.StatusBadRequest, "无效的playground请求, "+err.Error()) + return + } + if playgroundRequest.Group != "" { + if !service.GroupInUserUsableGroups(usingGroup, playgroundRequest.Group) && playgroundRequest.Group != usingGroup { + abortWithOpenAiMessage(c, http.StatusForbidden, "无权访问该分组") + return + } + usingGroup = playgroundRequest.Group + common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup) + } + } + channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(c, usingGroup, modelRequest.Model, 0) + if err != nil { + showGroup := usingGroup + if usingGroup == "auto" { + showGroup = fmt.Sprintf("auto(%s)", selectGroup) + } + message := fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(distributor): %s", showGroup, modelRequest.Model, err.Error()) + // 如果错误,但是渠道不为空,说明是数据库一致性问题 + //if channel != nil { + // common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) + // message = "数据库一致性已被破坏,请联系管理员" + //} + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message, string(types.ErrorCodeModelNotFound)) + return + } + if channel == nil { + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道(distributor)", usingGroup, modelRequest.Model), string(types.ErrorCodeModelNotFound)) + return + } + } + } + common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now()) + SetupContextForSelectedChannel(c, channel, modelRequest.Model) + c.Next() + } +} + +// getModelFromRequest 从请求中读取模型信息 +// 根据 Content-Type 自动处理: +// - application/json +// - application/x-www-form-urlencoded +// - multipart/form-data +func getModelFromRequest(c *gin.Context) (*ModelRequest, error) { + var modelRequest ModelRequest + err := common.UnmarshalBodyReusable(c, &modelRequest) + if err != nil { + return nil, errors.New("无效的请求, " + err.Error()) + } + return &modelRequest, nil +} + +func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { + var modelRequest ModelRequest + shouldSelectChannel := true + var err error + if strings.Contains(c.Request.URL.Path, "/mj/") { + relayMode := relayconstant.Path2RelayModeMidjourney(c.Request.URL.Path) + if relayMode == relayconstant.RelayModeMidjourneyTaskFetch || + relayMode == relayconstant.RelayModeMidjourneyTaskFetchByCondition || + relayMode == relayconstant.RelayModeMidjourneyNotify || + relayMode == relayconstant.RelayModeMidjourneyTaskImageSeed { + shouldSelectChannel = false + } else { + midjourneyRequest := dto.MidjourneyRequest{} + err = common.UnmarshalBodyReusable(c, &midjourneyRequest) + if err != nil { + return nil, false, errors.New("无效的midjourney请求, " + err.Error()) + } + midjourneyModel, mjErr, success := service.GetMjRequestModel(relayMode, &midjourneyRequest) + if mjErr != nil { + return nil, false, fmt.Errorf(mjErr.Description) + } + if midjourneyModel == "" { + if !success { + return nil, false, fmt.Errorf("无效的请求, 无法解析模型") + } else { + // task fetch, task fetch by condition, notify + shouldSelectChannel = false + } + } + modelRequest.Model = midjourneyModel + } + c.Set("relay_mode", relayMode) + } else if strings.Contains(c.Request.URL.Path, "/suno/") { + relayMode := relayconstant.Path2RelaySuno(c.Request.Method, c.Request.URL.Path) + if relayMode == relayconstant.RelayModeSunoFetch || + relayMode == relayconstant.RelayModeSunoFetchByID { + shouldSelectChannel = false + } else { + modelName := service.CoverTaskActionToModelName(constant.TaskPlatformSuno, c.Param("action")) + modelRequest.Model = modelName + } + c.Set("platform", string(constant.TaskPlatformSuno)) + c.Set("relay_mode", relayMode) + } else if strings.Contains(c.Request.URL.Path, "/v1/videos") { + //curl https://api.openai.com/v1/videos \ + // -H "Authorization: Bearer $OPENAI_API_KEY" \ + // -F "model=sora-2" \ + // -F "prompt=A calico cat playing a piano on stage" + // -F input_reference="@image.jpg" + relayMode := relayconstant.RelayModeUnknown + if c.Request.Method == http.MethodPost { + relayMode = relayconstant.RelayModeVideoSubmit + req, err := getModelFromRequest(c) + if err != nil { + return nil, false, err + } + if req != nil { + modelRequest.Model = req.Model + } + } else if c.Request.Method == http.MethodGet { + relayMode = relayconstant.RelayModeVideoFetchByID + shouldSelectChannel = false + } + c.Set("relay_mode", relayMode) + } else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") { + relayMode := relayconstant.RelayModeUnknown + if c.Request.Method == http.MethodPost { + req, err := getModelFromRequest(c) + if err != nil { + return nil, false, err + } + modelRequest.Model = req.Model + relayMode = relayconstant.RelayModeVideoSubmit + } else if c.Request.Method == http.MethodGet { + relayMode = relayconstant.RelayModeVideoFetchByID + shouldSelectChannel = false + } + if _, ok := c.Get("relay_mode"); !ok { + c.Set("relay_mode", relayMode) + } + } else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") { + // Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent + relayMode := relayconstant.RelayModeGemini + modelName := extractModelNameFromGeminiPath(c.Request.URL.Path) + if modelName != "" { + modelRequest.Model = modelName + } + c.Set("relay_mode", relayMode) + } else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { + req, err := getModelFromRequest(c) + if err != nil { + return nil, false, err + } + modelRequest.Model = req.Model + } + if strings.HasPrefix(c.Request.URL.Path, "/v1/realtime") { + //wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01 + modelRequest.Model = c.Query("model") + } + if strings.HasPrefix(c.Request.URL.Path, "/v1/moderations") { + if modelRequest.Model == "" { + modelRequest.Model = "text-moderation-stable" + } + } + if strings.HasSuffix(c.Request.URL.Path, "embeddings") { + if modelRequest.Model == "" { + modelRequest.Model = c.Param("model") + } + } + if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") { + modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e") + } else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") { + //modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1") + contentType := c.ContentType() + if slices.Contains([]string{gin.MIMEPOSTForm, gin.MIMEMultipartPOSTForm}, contentType) { + req, err := getModelFromRequest(c) + if err == nil && req.Model != "" { + modelRequest.Model = req.Model + } + } + } + if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") { + relayMode := relayconstant.RelayModeAudioSpeech + if strings.HasPrefix(c.Request.URL.Path, "/v1/audio/speech") { + + modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "tts-1") + } else if strings.HasPrefix(c.Request.URL.Path, "/v1/audio/translations") { + // 先尝试从请求读取 + if req, err := getModelFromRequest(c); err == nil && req.Model != "" { + modelRequest.Model = req.Model + } + modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "whisper-1") + relayMode = relayconstant.RelayModeAudioTranslation + } else if strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") { + // 先尝试从请求读取 + if req, err := getModelFromRequest(c); err == nil && req.Model != "" { + modelRequest.Model = req.Model + } + modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "whisper-1") + relayMode = relayconstant.RelayModeAudioTranscription + } + c.Set("relay_mode", relayMode) + } + if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") { + // playground chat completions + req, err := getModelFromRequest(c) + if err != nil { + return nil, false, err + } + modelRequest.Model = req.Model + modelRequest.Group = req.Group + common.SetContextKey(c, constant.ContextKeyTokenGroup, modelRequest.Group) + } + return &modelRequest, shouldSelectChannel, nil +} + +func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError { + c.Set("original_model", modelName) // for retry + if channel == nil { + return types.NewError(errors.New("channel is nil"), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) + } + common.SetContextKey(c, constant.ContextKeyChannelId, channel.Id) + common.SetContextKey(c, constant.ContextKeyChannelName, channel.Name) + common.SetContextKey(c, constant.ContextKeyChannelType, channel.Type) + common.SetContextKey(c, constant.ContextKeyChannelCreateTime, channel.CreatedTime) + common.SetContextKey(c, constant.ContextKeyChannelSetting, channel.GetSetting()) + common.SetContextKey(c, constant.ContextKeyChannelOtherSetting, channel.GetOtherSettings()) + common.SetContextKey(c, constant.ContextKeyChannelParamOverride, channel.GetParamOverride()) + common.SetContextKey(c, constant.ContextKeyChannelHeaderOverride, channel.GetHeaderOverride()) + if nil != channel.OpenAIOrganization && *channel.OpenAIOrganization != "" { + common.SetContextKey(c, constant.ContextKeyChannelOrganization, *channel.OpenAIOrganization) + } + common.SetContextKey(c, constant.ContextKeyChannelAutoBan, channel.GetAutoBan()) + common.SetContextKey(c, constant.ContextKeyChannelModelMapping, channel.GetModelMapping()) + common.SetContextKey(c, constant.ContextKeyChannelStatusCodeMapping, channel.GetStatusCodeMapping()) + + key, index, newAPIError := channel.GetNextEnabledKey() + if newAPIError != nil { + return newAPIError + } + if channel.ChannelInfo.IsMultiKey { + common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, true) + common.SetContextKey(c, constant.ContextKeyChannelMultiKeyIndex, index) + } else { + // 必须设置为 false,否则在重试到单个 key 的时候会导致日志显示错误 + common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, false) + } + // c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key)) + common.SetContextKey(c, constant.ContextKeyChannelKey, key) + common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, channel.GetBaseURL()) + + common.SetContextKey(c, constant.ContextKeySystemPromptOverride, false) + + // TODO: api_version统一 + switch channel.Type { + case constant.ChannelTypeAzure: + c.Set("api_version", channel.Other) + case constant.ChannelTypeVertexAi: + c.Set("region", channel.Other) + case constant.ChannelTypeXunfei: + c.Set("api_version", channel.Other) + case constant.ChannelTypeGemini: + c.Set("api_version", channel.Other) + case constant.ChannelTypeAli: + c.Set("plugin", channel.Other) + case constant.ChannelCloudflare: + c.Set("api_version", channel.Other) + case constant.ChannelTypeMokaAI: + c.Set("api_version", channel.Other) + case constant.ChannelTypeCoze: + c.Set("bot_id", channel.Other) + } + return nil +} + +// extractModelNameFromGeminiPath 从 Gemini API URL 路径中提取模型名 +// 输入格式: /v1beta/models/gemini-2.0-flash:generateContent +// 输出: gemini-2.0-flash +func extractModelNameFromGeminiPath(path string) string { + // 查找 "/models/" 的位置 + modelsPrefix := "/models/" + modelsIndex := strings.Index(path, modelsPrefix) + if modelsIndex == -1 { + return "" + } + + // 从 "/models/" 之后开始提取 + startIndex := modelsIndex + len(modelsPrefix) + if startIndex >= len(path) { + return "" + } + + // 查找 ":" 的位置,模型名在 ":" 之前 + colonIndex := strings.Index(path[startIndex:], ":") + if colonIndex == -1 { + // 如果没有找到 ":",返回从 "/models/" 到路径结尾的部分 + return path[startIndex:] + } + + // 返回模型名部分 + return path[startIndex : startIndex+colonIndex] +} diff --git a/middleware/email-verification-rate-limit.go b/middleware/email-verification-rate-limit.go new file mode 100644 index 0000000000000000000000000000000000000000..470d7731cb0f2d761d6c9d86fc2818dad8f9aa31 --- /dev/null +++ b/middleware/email-verification-rate-limit.go @@ -0,0 +1,81 @@ +package middleware + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/QuantumNous/new-api/common" + + "github.com/gin-gonic/gin" +) + +const ( + EmailVerificationRateLimitMark = "EV" + EmailVerificationMaxRequests = 2 // 30秒内最多2次 + EmailVerificationDuration = 30 // 30秒时间窗口 +) + +func redisEmailVerificationRateLimiter(c *gin.Context) { + ctx := context.Background() + rdb := common.RDB + key := "emailVerification:" + EmailVerificationRateLimitMark + ":" + c.ClientIP() + + count, err := rdb.Incr(ctx, key).Result() + if err != nil { + // fallback + memoryEmailVerificationRateLimiter(c) + return + } + + // 第一次设置键时设置过期时间 + if count == 1 { + _ = rdb.Expire(ctx, key, time.Duration(EmailVerificationDuration)*time.Second).Err() + } + + // 检查是否超出限制 + if count <= int64(EmailVerificationMaxRequests) { + c.Next() + return + } + + // 获取剩余等待时间 + ttl, err := rdb.TTL(ctx, key).Result() + waitSeconds := int64(EmailVerificationDuration) + if err == nil && ttl > 0 { + waitSeconds = int64(ttl.Seconds()) + } + + c.JSON(http.StatusTooManyRequests, gin.H{ + "success": false, + "message": fmt.Sprintf("发送过于频繁,请等待 %d 秒后再试", waitSeconds), + }) + c.Abort() +} + +func memoryEmailVerificationRateLimiter(c *gin.Context) { + key := EmailVerificationRateLimitMark + ":" + c.ClientIP() + + if !inMemoryRateLimiter.Request(key, EmailVerificationMaxRequests, EmailVerificationDuration) { + c.JSON(http.StatusTooManyRequests, gin.H{ + "success": false, + "message": "发送过于频繁,请稍后再试", + }) + c.Abort() + return + } + + c.Next() +} + +func EmailVerificationRateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + if common.RedisEnabled { + redisEmailVerificationRateLimiter(c) + } else { + inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration) + memoryEmailVerificationRateLimiter(c) + } + } +} diff --git a/middleware/gzip.go b/middleware/gzip.go new file mode 100644 index 0000000000000000000000000000000000000000..7fe2f3be308c8b56b74627d057e518e47ea65400 --- /dev/null +++ b/middleware/gzip.go @@ -0,0 +1,39 @@ +package middleware + +import ( + "compress/gzip" + "io" + "net/http" + + "github.com/andybalholm/brotli" + "github.com/gin-gonic/gin" +) + +func DecompressRequestMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Body == nil || c.Request.Method == http.MethodGet { + c.Next() + return + } + switch c.GetHeader("Content-Encoding") { + case "gzip": + gzipReader, err := gzip.NewReader(c.Request.Body) + if err != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + defer gzipReader.Close() + + // Replace the request body with the decompressed data + c.Request.Body = io.NopCloser(gzipReader) + c.Request.Header.Del("Content-Encoding") + case "br": + reader := brotli.NewReader(c.Request.Body) + c.Request.Body = io.NopCloser(reader) + c.Request.Header.Del("Content-Encoding") + } + + // Continue processing the request + c.Next() + } +} diff --git a/middleware/jimeng_adapter.go b/middleware/jimeng_adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..3e3dd7ae52e01d52357a36956aab89b1e4c11055 --- /dev/null +++ b/middleware/jimeng_adapter.go @@ -0,0 +1,67 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/gin-gonic/gin" +) + +func JimengRequestConvert() func(c *gin.Context) { + return func(c *gin.Context) { + action := c.Query("Action") + if action == "" { + abortWithOpenAiMessage(c, http.StatusBadRequest, "Action query parameter is required") + return + } + + // Handle Jimeng official API request + var originalReq map[string]interface{} + if err := common.UnmarshalBodyReusable(c, &originalReq); err != nil { + abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid request body") + return + } + model, _ := originalReq["req_key"].(string) + prompt, _ := originalReq["prompt"].(string) + + unifiedReq := map[string]interface{}{ + "model": model, + "prompt": prompt, + "metadata": originalReq, + } + + jsonData, err := json.Marshal(unifiedReq) + if err != nil { + abortWithOpenAiMessage(c, http.StatusInternalServerError, "Failed to marshal request body") + return + } + + // Update request body + c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData)) + c.Set(common.KeyRequestBody, jsonData) + + if image, ok := originalReq["image"]; !ok || image == "" { + c.Set("action", constant.TaskActionTextGenerate) + } + + c.Request.URL.Path = "/v1/video/generations" + + if action == "CVSync2AsyncGetResult" { + taskId, ok := originalReq["task_id"].(string) + if !ok || taskId == "" { + abortWithOpenAiMessage(c, http.StatusBadRequest, "task_id is required for CVSync2AsyncGetResult") + return + } + c.Request.URL.Path = "/v1/video/generations/" + taskId + c.Request.Method = http.MethodGet + c.Set("task_id", taskId) + c.Set("relay_mode", relayconstant.RelayModeVideoFetchByID) + } + c.Next() + } +} diff --git a/middleware/kling_adapter.go b/middleware/kling_adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..e200379c0c3413103d5bfa8a0b817278e5e9fbf0 --- /dev/null +++ b/middleware/kling_adapter.go @@ -0,0 +1,52 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "io" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + + "github.com/gin-gonic/gin" +) + +func KlingRequestConvert() func(c *gin.Context) { + return func(c *gin.Context) { + var originalReq map[string]interface{} + if err := common.UnmarshalBodyReusable(c, &originalReq); err != nil { + c.Next() + return + } + + // Support both model_name and model fields + model, _ := originalReq["model_name"].(string) + if model == "" { + model, _ = originalReq["model"].(string) + } + prompt, _ := originalReq["prompt"].(string) + + unifiedReq := map[string]interface{}{ + "model": model, + "prompt": prompt, + "metadata": originalReq, + } + + jsonData, err := json.Marshal(unifiedReq) + if err != nil { + c.Next() + return + } + + // Rewrite request body and path + c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData)) + c.Request.URL.Path = "/v1/video/generations" + if image, ok := originalReq["image"]; !ok || image == "" { + c.Set("action", constant.TaskActionTextGenerate) + } + + // We have to reset the request body for the next handlers + c.Set(common.KeyRequestBody, jsonData) + c.Next() + } +} diff --git a/middleware/logger.go b/middleware/logger.go new file mode 100644 index 0000000000000000000000000000000000000000..b4ed8c89d7eea22d14557cf73d55f95cd1d518fc --- /dev/null +++ b/middleware/logger.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "fmt" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" +) + +func SetUpLogger(server *gin.Engine) { + server.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + var requestID string + if param.Keys != nil { + requestID = param.Keys[common.RequestIdKey].(string) + } + return fmt.Sprintf("[GIN] %s | %s | %3d | %13v | %15s | %7s %s\n", + param.TimeStamp.Format("2006/01/02 - 15:04:05"), + requestID, + param.StatusCode, + param.Latency, + param.ClientIP, + param.Method, + param.Path, + ) + })) +} diff --git a/middleware/model-rate-limit.go b/middleware/model-rate-limit.go new file mode 100644 index 0000000000000000000000000000000000000000..80a3995df0972e0bb3357ca18274b6b5a2c1c7af --- /dev/null +++ b/middleware/model-rate-limit.go @@ -0,0 +1,200 @@ +package middleware + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/common/limiter" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/setting" + + "github.com/gin-gonic/gin" + "github.com/go-redis/redis/v8" +) + +const ( + ModelRequestRateLimitCountMark = "MRRL" + ModelRequestRateLimitSuccessCountMark = "MRRLS" +) + +// 检查Redis中的请求限制 +func checkRedisRateLimit(ctx context.Context, rdb *redis.Client, key string, maxCount int, duration int64) (bool, error) { + // 如果maxCount为0,表示不限制 + if maxCount == 0 { + return true, nil + } + + // 获取当前计数 + length, err := rdb.LLen(ctx, key).Result() + if err != nil { + return false, err + } + + // 如果未达到限制,允许请求 + if length < int64(maxCount) { + return true, nil + } + + // 检查时间窗口 + oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result() + oldTime, err := time.Parse(timeFormat, oldTimeStr) + if err != nil { + return false, err + } + + nowTimeStr := time.Now().Format(timeFormat) + nowTime, err := time.Parse(timeFormat, nowTimeStr) + if err != nil { + return false, err + } + // 如果在时间窗口内已达到限制,拒绝请求 + subTime := nowTime.Sub(oldTime).Seconds() + if int64(subTime) < duration { + rdb.Expire(ctx, key, time.Duration(setting.ModelRequestRateLimitDurationMinutes)*time.Minute) + return false, nil + } + + return true, nil +} + +// 记录Redis请求 +func recordRedisRequest(ctx context.Context, rdb *redis.Client, key string, maxCount int) { + // 如果maxCount为0,不记录请求 + if maxCount == 0 { + return + } + + now := time.Now().Format(timeFormat) + rdb.LPush(ctx, key, now) + rdb.LTrim(ctx, key, 0, int64(maxCount-1)) + rdb.Expire(ctx, key, time.Duration(setting.ModelRequestRateLimitDurationMinutes)*time.Minute) +} + +// Redis限流处理器 +func redisRateLimitHandler(duration int64, totalMaxCount, successMaxCount int) gin.HandlerFunc { + return func(c *gin.Context) { + userId := strconv.Itoa(c.GetInt("id")) + ctx := context.Background() + rdb := common.RDB + + // 1. 检查成功请求数限制 + successKey := fmt.Sprintf("rateLimit:%s:%s", ModelRequestRateLimitSuccessCountMark, userId) + allowed, err := checkRedisRateLimit(ctx, rdb, successKey, successMaxCount, duration) + if err != nil { + fmt.Println("检查成功请求数限制失败:", err.Error()) + abortWithOpenAiMessage(c, http.StatusInternalServerError, "rate_limit_check_failed") + return + } + if !allowed { + abortWithOpenAiMessage(c, http.StatusTooManyRequests, fmt.Sprintf("您已达到请求数限制:%d分钟内最多请求%d次", setting.ModelRequestRateLimitDurationMinutes, successMaxCount)) + return + } + + //2.检查总请求数限制并记录总请求(当totalMaxCount为0时会自动跳过,使用令牌桶限流器 + if totalMaxCount > 0 { + totalKey := fmt.Sprintf("rateLimit:%s", userId) + // 初始化 + tb := limiter.New(ctx, rdb) + allowed, err = tb.Allow( + ctx, + totalKey, + limiter.WithCapacity(int64(totalMaxCount)*duration), + limiter.WithRate(int64(totalMaxCount)), + limiter.WithRequested(duration), + ) + + if err != nil { + fmt.Println("检查总请求数限制失败:", err.Error()) + abortWithOpenAiMessage(c, http.StatusInternalServerError, "rate_limit_check_failed") + return + } + + if !allowed { + abortWithOpenAiMessage(c, http.StatusTooManyRequests, fmt.Sprintf("您已达到总请求数限制:%d分钟内最多请求%d次,包括失败次数,请检查您的请求是否正确", setting.ModelRequestRateLimitDurationMinutes, totalMaxCount)) + } + } + + // 4. 处理请求 + c.Next() + + // 5. 如果请求成功,记录成功请求 + if c.Writer.Status() < 400 { + recordRedisRequest(ctx, rdb, successKey, successMaxCount) + } + } +} + +// 内存限流处理器 +func memoryRateLimitHandler(duration int64, totalMaxCount, successMaxCount int) gin.HandlerFunc { + inMemoryRateLimiter.Init(time.Duration(setting.ModelRequestRateLimitDurationMinutes) * time.Minute) + + return func(c *gin.Context) { + userId := strconv.Itoa(c.GetInt("id")) + totalKey := ModelRequestRateLimitCountMark + userId + successKey := ModelRequestRateLimitSuccessCountMark + userId + + // 1. 检查总请求数限制(当totalMaxCount为0时跳过) + if totalMaxCount > 0 && !inMemoryRateLimiter.Request(totalKey, totalMaxCount, duration) { + c.Status(http.StatusTooManyRequests) + c.Abort() + return + } + + // 2. 检查成功请求数限制 + // 使用一个临时key来检查限制,这样可以避免实际记录 + checkKey := successKey + "_check" + if !inMemoryRateLimiter.Request(checkKey, successMaxCount, duration) { + c.Status(http.StatusTooManyRequests) + c.Abort() + return + } + + // 3. 处理请求 + c.Next() + + // 4. 如果请求成功,记录到实际的成功请求计数中 + if c.Writer.Status() < 400 { + inMemoryRateLimiter.Request(successKey, successMaxCount, duration) + } + } +} + +// ModelRequestRateLimit 模型请求限流中间件 +func ModelRequestRateLimit() func(c *gin.Context) { + return func(c *gin.Context) { + // 在每个请求时检查是否启用限流 + if !setting.ModelRequestRateLimitEnabled { + c.Next() + return + } + + // 计算限流参数 + duration := int64(setting.ModelRequestRateLimitDurationMinutes * 60) + totalMaxCount := setting.ModelRequestRateLimitCount + successMaxCount := setting.ModelRequestRateLimitSuccessCount + + // 获取分组 + group := common.GetContextKeyString(c, constant.ContextKeyTokenGroup) + if group == "" { + group = common.GetContextKeyString(c, constant.ContextKeyUserGroup) + } + + //获取分组的限流配置 + groupTotalCount, groupSuccessCount, found := setting.GetGroupRateLimit(group) + if found { + totalMaxCount = groupTotalCount + successMaxCount = groupSuccessCount + } + + // 根据存储类型选择并执行限流处理器 + if common.RedisEnabled { + redisRateLimitHandler(duration, totalMaxCount, successMaxCount)(c) + } else { + memoryRateLimitHandler(duration, totalMaxCount, successMaxCount)(c) + } + } +} diff --git a/middleware/rate-limit.go b/middleware/rate-limit.go new file mode 100644 index 0000000000000000000000000000000000000000..866542e17e13ec1912959a4b9e6aeedc7d2b261d --- /dev/null +++ b/middleware/rate-limit.go @@ -0,0 +1,117 @@ +package middleware + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" +) + +var timeFormat = "2006-01-02T15:04:05.000Z" + +var inMemoryRateLimiter common.InMemoryRateLimiter + +var defNext = func(c *gin.Context) { + c.Next() +} + +func redisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) { + ctx := context.Background() + rdb := common.RDB + key := "rateLimit:" + mark + c.ClientIP() + listLength, err := rdb.LLen(ctx, key).Result() + if err != nil { + fmt.Println(err.Error()) + c.Status(http.StatusInternalServerError) + c.Abort() + return + } + if listLength < int64(maxRequestNum) { + rdb.LPush(ctx, key, time.Now().Format(timeFormat)) + rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) + } else { + oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result() + oldTime, err := time.Parse(timeFormat, oldTimeStr) + if err != nil { + fmt.Println(err) + c.Status(http.StatusInternalServerError) + c.Abort() + return + } + nowTimeStr := time.Now().Format(timeFormat) + nowTime, err := time.Parse(timeFormat, nowTimeStr) + if err != nil { + fmt.Println(err) + c.Status(http.StatusInternalServerError) + c.Abort() + return + } + // time.Since will return negative number! + // See: https://stackoverflow.com/questions/50970900/why-is-time-since-returning-negative-durations-on-windows + if int64(nowTime.Sub(oldTime).Seconds()) < duration { + rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) + c.Status(http.StatusTooManyRequests) + c.Abort() + return + } else { + rdb.LPush(ctx, key, time.Now().Format(timeFormat)) + rdb.LTrim(ctx, key, 0, int64(maxRequestNum-1)) + rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) + } + } +} + +func memoryRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) { + key := mark + c.ClientIP() + if !inMemoryRateLimiter.Request(key, maxRequestNum, duration) { + c.Status(http.StatusTooManyRequests) + c.Abort() + return + } +} + +func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) { + if common.RedisEnabled { + return func(c *gin.Context) { + redisRateLimiter(c, maxRequestNum, duration, mark) + } + } else { + // It's safe to call multi times. + inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration) + return func(c *gin.Context) { + memoryRateLimiter(c, maxRequestNum, duration, mark) + } + } +} + +func GlobalWebRateLimit() func(c *gin.Context) { + if common.GlobalWebRateLimitEnable { + return rateLimitFactory(common.GlobalWebRateLimitNum, common.GlobalWebRateLimitDuration, "GW") + } + return defNext +} + +func GlobalAPIRateLimit() func(c *gin.Context) { + if common.GlobalApiRateLimitEnable { + return rateLimitFactory(common.GlobalApiRateLimitNum, common.GlobalApiRateLimitDuration, "GA") + } + return defNext +} + +func CriticalRateLimit() func(c *gin.Context) { + if common.CriticalRateLimitEnable { + return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT") + } + return defNext +} + +func DownloadRateLimit() func(c *gin.Context) { + return rateLimitFactory(common.DownloadRateLimitNum, common.DownloadRateLimitDuration, "DW") +} + +func UploadRateLimit() func(c *gin.Context) { + return rateLimitFactory(common.UploadRateLimitNum, common.UploadRateLimitDuration, "UP") +} diff --git a/middleware/recover.go b/middleware/recover.go new file mode 100644 index 0000000000000000000000000000000000000000..745a61015dae3c913bcc745e2f7cc27d1fcbe3fe --- /dev/null +++ b/middleware/recover.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "fmt" + "net/http" + "runtime/debug" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" +) + +func RelayPanicRecover() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + common.SysLog(fmt.Sprintf("panic detected: %v", err)) + common.SysLog(fmt.Sprintf("stacktrace from panic: %s", string(debug.Stack()))) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err), + "type": "new_api_panic", + }, + }) + c.Abort() + } + }() + c.Next() + } +} diff --git a/middleware/request-id.go b/middleware/request-id.go new file mode 100644 index 0000000000000000000000000000000000000000..2b3e5ddc1b4217be56b5e1fb43b85c44df7844cd --- /dev/null +++ b/middleware/request-id.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "context" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" +) + +func RequestId() func(c *gin.Context) { + return func(c *gin.Context) { + id := common.GetTimeString() + common.GetRandomString(8) + c.Set(common.RequestIdKey, id) + ctx := context.WithValue(c.Request.Context(), common.RequestIdKey, id) + c.Request = c.Request.WithContext(ctx) + c.Header(common.RequestIdKey, id) + c.Next() + } +} diff --git a/middleware/secure_verification.go b/middleware/secure_verification.go new file mode 100644 index 0000000000000000000000000000000000000000..19fae9a593b561eed24720ad4f129424f0a8cee9 --- /dev/null +++ b/middleware/secure_verification.go @@ -0,0 +1,131 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +const ( + // SecureVerificationSessionKey 安全验证的 session key(与 controller 保持一致) + SecureVerificationSessionKey = "secure_verified_at" + // SecureVerificationTimeout 验证有效期(秒) + SecureVerificationTimeout = 300 // 5分钟 +) + +// SecureVerificationRequired 安全验证中间件 +// 检查用户是否在有效时间内通过了安全验证 +// 如果未验证或验证已过期,返回 401 错误 +func SecureVerificationRequired() gin.HandlerFunc { + return func(c *gin.Context) { + // 检查用户是否已登录 + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + c.Abort() + return + } + + // 检查 session 中的验证时间戳 + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "需要安全验证", + "code": "VERIFICATION_REQUIRED", + }) + c.Abort() + return + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + // session 数据格式错误 + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "验证状态异常,请重新验证", + "code": "VERIFICATION_INVALID", + }) + c.Abort() + return + } + + // 检查验证是否过期 + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + // 验证已过期,清除 session + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "验证已过期,请重新验证", + "code": "VERIFICATION_EXPIRED", + }) + c.Abort() + return + } + + // 验证有效,继续处理请求 + c.Next() + } +} + +// OptionalSecureVerification 可选的安全验证中间件 +// 如果用户已验证,则在 context 中设置标记,但不阻止请求继续 +// 用于某些需要区分是否已验证的场景 +func OptionalSecureVerification() gin.HandlerFunc { + return func(c *gin.Context) { + userId := c.GetInt("id") + if userId == 0 { + c.Set("secure_verified", false) + c.Next() + return + } + + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + c.Set("secure_verified", false) + c.Next() + return + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + c.Set("secure_verified", false) + c.Next() + return + } + + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.Set("secure_verified", false) + c.Next() + return + } + + c.Set("secure_verified", true) + c.Set("secure_verified_at", verifiedAt) + c.Next() + } +} + +// ClearSecureVerification 清除安全验证状态 +// 用于用户登出或需要强制重新验证的场景 +func ClearSecureVerification(c *gin.Context) { + session := sessions.Default(c) + session.Delete(SecureVerificationSessionKey) + _ = session.Save() +} diff --git a/middleware/stats.go b/middleware/stats.go new file mode 100644 index 0000000000000000000000000000000000000000..e49e5699170eaca8de02e4a5c74c1e06f91c2459 --- /dev/null +++ b/middleware/stats.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "sync/atomic" + + "github.com/gin-gonic/gin" +) + +// HTTPStats 存储HTTP统计信息 +type HTTPStats struct { + activeConnections int64 +} + +var globalStats = &HTTPStats{} + +// StatsMiddleware 统计中间件 +func StatsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 增加活跃连接数 + atomic.AddInt64(&globalStats.activeConnections, 1) + + // 确保在请求结束时减少连接数 + defer func() { + atomic.AddInt64(&globalStats.activeConnections, -1) + }() + + c.Next() + } +} + +// StatsInfo 统计信息结构 +type StatsInfo struct { + ActiveConnections int64 `json:"active_connections"` +} + +// GetStats 获取统计信息 +func GetStats() StatsInfo { + return StatsInfo{ + ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections), + } +} diff --git a/middleware/turnstile-check.go b/middleware/turnstile-check.go new file mode 100644 index 0000000000000000000000000000000000000000..af87fad4423c7ea2bd2d088a59ebb92015d96d02 --- /dev/null +++ b/middleware/turnstile-check.go @@ -0,0 +1,81 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/url" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type turnstileCheckResponse struct { + Success bool `json:"success"` +} + +func TurnstileCheck() gin.HandlerFunc { + return func(c *gin.Context) { + if common.TurnstileCheckEnabled { + session := sessions.Default(c) + turnstileChecked := session.Get("turnstile") + if turnstileChecked != nil { + c.Next() + return + } + response := c.Query("turnstile") + if response == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Turnstile token 为空", + }) + c.Abort() + return + } + rawRes, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{ + "secret": {common.TurnstileSecretKey}, + "response": {response}, + "remoteip": {c.ClientIP()}, + }) + if err != nil { + common.SysLog(err.Error()) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + c.Abort() + return + } + defer rawRes.Body.Close() + var res turnstileCheckResponse + err = json.NewDecoder(rawRes.Body).Decode(&res) + if err != nil { + common.SysLog(err.Error()) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + c.Abort() + return + } + if !res.Success { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Turnstile 校验失败,请刷新重试!", + }) + c.Abort() + return + } + session.Set("turnstile", true) + err = session.Save() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": "无法保存会话信息,请重试", + "success": false, + }) + return + } + } + c.Next() + } +} diff --git a/middleware/utils.go b/middleware/utils.go new file mode 100644 index 0000000000000000000000000000000000000000..24caa83c7c10cfe5a9d06e1d8480040ddc4f066a --- /dev/null +++ b/middleware/utils.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "fmt" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/gin-gonic/gin" +) + +func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string, code ...string) { + codeStr := "" + if len(code) > 0 { + codeStr = code[0] + } + userId := c.GetInt("id") + c.JSON(statusCode, gin.H{ + "error": gin.H{ + "message": common.MessageWithRequestId(message, c.GetString(common.RequestIdKey)), + "type": "new_api_error", + "code": codeStr, + }, + }) + c.Abort() + logger.LogError(c.Request.Context(), fmt.Sprintf("user %d | %s", userId, message)) +} + +func abortWithMidjourneyMessage(c *gin.Context, statusCode int, code int, description string) { + c.JSON(statusCode, gin.H{ + "description": description, + "type": "new_api_error", + "code": code, + }) + c.Abort() + logger.LogError(c.Request.Context(), description) +} diff --git a/model/ability.go b/model/ability.go new file mode 100644 index 0000000000000000000000000000000000000000..1d7c53fa58050f98c8cf104d1bddd99d16727306 --- /dev/null +++ b/model/ability.go @@ -0,0 +1,341 @@ +package model + +import ( + "errors" + "fmt" + "strings" + "sync" + + "github.com/QuantumNous/new-api/common" + + "github.com/samber/lo" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Ability struct { + Group string `json:"group" gorm:"type:varchar(64);primaryKey;autoIncrement:false"` + Model string `json:"model" gorm:"type:varchar(255);primaryKey;autoIncrement:false"` + ChannelId int `json:"channel_id" gorm:"primaryKey;autoIncrement:false;index"` + Enabled bool `json:"enabled"` + Priority *int64 `json:"priority" gorm:"bigint;default:0;index"` + Weight uint `json:"weight" gorm:"default:0;index"` + Tag *string `json:"tag" gorm:"index"` +} + +type AbilityWithChannel struct { + Ability + ChannelType int `json:"channel_type"` +} + +func GetAllEnableAbilityWithChannels() ([]AbilityWithChannel, error) { + var abilities []AbilityWithChannel + err := DB.Table("abilities"). + Select("abilities.*, channels.type as channel_type"). + Joins("left join channels on abilities.channel_id = channels.id"). + Where("abilities.enabled = ?", true). + Scan(&abilities).Error + return abilities, err +} + +func GetGroupEnabledModels(group string) []string { + var models []string + // Find distinct models + DB.Table("abilities").Where(commonGroupCol+" = ? and enabled = ?", group, true).Distinct("model").Pluck("model", &models) + return models +} + +func GetEnabledModels() []string { + var models []string + // Find distinct models + DB.Table("abilities").Where("enabled = ?", true).Distinct("model").Pluck("model", &models) + return models +} + +func GetAllEnableAbilities() []Ability { + var abilities []Ability + DB.Find(&abilities, "enabled = ?", true) + return abilities +} + +func getPriority(group string, model string, retry int) (int, error) { + + var priorities []int + err := DB.Model(&Ability{}). + Select("DISTINCT(priority)"). + Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true). + Order("priority DESC"). // 按优先级降序排序 + Pluck("priority", &priorities).Error // Pluck用于将查询的结果直接扫描到一个切片中 + + if err != nil { + // 处理错误 + return 0, err + } + + if len(priorities) == 0 { + // 如果没有查询到优先级,则返回错误 + return 0, errors.New("数据库一致性被破坏") + } + + // 确定要使用的优先级 + var priorityToUse int + if retry >= len(priorities) { + // 如果重试次数大于优先级数,则使用最小的优先级 + priorityToUse = priorities[len(priorities)-1] + } else { + priorityToUse = priorities[retry] + } + return priorityToUse, nil +} + +func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) { + maxPrioritySubQuery := DB.Model(&Ability{}).Select("MAX(priority)").Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true) + channelQuery := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = (?)", group, model, true, maxPrioritySubQuery) + if retry != 0 { + priority, err := getPriority(group, model, retry) + if err != nil { + return nil, err + } else { + channelQuery = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = ?", group, model, true, priority) + } + } + + return channelQuery, nil +} + +func GetChannel(group string, model string, retry int) (*Channel, error) { + var abilities []Ability + + var err error = nil + channelQuery, err := getChannelQuery(group, model, retry) + if err != nil { + return nil, err + } + if common.UsingSQLite || common.UsingPostgreSQL { + err = channelQuery.Order("weight DESC").Find(&abilities).Error + } else { + err = channelQuery.Order("weight DESC").Find(&abilities).Error + } + if err != nil { + return nil, err + } + channel := Channel{} + if len(abilities) > 0 { + // Randomly choose one + weightSum := uint(0) + for _, ability_ := range abilities { + weightSum += ability_.Weight + 10 + } + // Randomly choose one + weight := common.GetRandomInt(int(weightSum)) + for _, ability_ := range abilities { + weight -= int(ability_.Weight) + 10 + //log.Printf("weight: %d, ability weight: %d", weight, *ability_.Weight) + if weight <= 0 { + channel.Id = ability_.ChannelId + break + } + } + } else { + return nil, nil + } + err = DB.First(&channel, "id = ?", channel.Id).Error + return &channel, err +} + +func (channel *Channel) AddAbilities(tx *gorm.DB) error { + models_ := strings.Split(channel.Models, ",") + groups_ := strings.Split(channel.Group, ",") + abilitySet := make(map[string]struct{}) + abilities := make([]Ability, 0, len(models_)) + for _, model := range models_ { + for _, group := range groups_ { + key := group + "|" + model + if _, exists := abilitySet[key]; exists { + continue + } + abilitySet[key] = struct{}{} + ability := Ability{ + Group: group, + Model: model, + ChannelId: channel.Id, + Enabled: channel.Status == common.ChannelStatusEnabled, + Priority: channel.Priority, + Weight: uint(channel.GetWeight()), + Tag: channel.Tag, + } + abilities = append(abilities, ability) + } + } + if len(abilities) == 0 { + return nil + } + // choose DB or provided tx + useDB := DB + if tx != nil { + useDB = tx + } + for _, chunk := range lo.Chunk(abilities, 50) { + err := useDB.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error + if err != nil { + return err + } + } + return nil +} + +func (channel *Channel) DeleteAbilities() error { + return DB.Where("channel_id = ?", channel.Id).Delete(&Ability{}).Error +} + +// UpdateAbilities updates abilities of this channel. +// Make sure the channel is completed before calling this function. +func (channel *Channel) UpdateAbilities(tx *gorm.DB) error { + isNewTx := false + // 如果没有传入事务,创建新的事务 + if tx == nil { + tx = DB.Begin() + if tx.Error != nil { + return tx.Error + } + isNewTx = true + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + } + + // First delete all abilities of this channel + err := tx.Where("channel_id = ?", channel.Id).Delete(&Ability{}).Error + if err != nil { + if isNewTx { + tx.Rollback() + } + return err + } + + // Then add new abilities + models_ := strings.Split(channel.Models, ",") + groups_ := strings.Split(channel.Group, ",") + abilitySet := make(map[string]struct{}) + abilities := make([]Ability, 0, len(models_)) + for _, model := range models_ { + for _, group := range groups_ { + key := group + "|" + model + if _, exists := abilitySet[key]; exists { + continue + } + abilitySet[key] = struct{}{} + ability := Ability{ + Group: group, + Model: model, + ChannelId: channel.Id, + Enabled: channel.Status == common.ChannelStatusEnabled, + Priority: channel.Priority, + Weight: uint(channel.GetWeight()), + Tag: channel.Tag, + } + abilities = append(abilities, ability) + } + } + + if len(abilities) > 0 { + for _, chunk := range lo.Chunk(abilities, 50) { + err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error + if err != nil { + if isNewTx { + tx.Rollback() + } + return err + } + } + } + + // 如果是新创建的事务,需要提交 + if isNewTx { + return tx.Commit().Error + } + + return nil +} + +func UpdateAbilityStatus(channelId int, status bool) error { + return DB.Model(&Ability{}).Where("channel_id = ?", channelId).Select("enabled").Update("enabled", status).Error +} + +func UpdateAbilityStatusByTag(tag string, status bool) error { + return DB.Model(&Ability{}).Where("tag = ?", tag).Select("enabled").Update("enabled", status).Error +} + +func UpdateAbilityByTag(tag string, newTag *string, priority *int64, weight *uint) error { + ability := Ability{} + if newTag != nil { + ability.Tag = newTag + } + if priority != nil { + ability.Priority = priority + } + if weight != nil { + ability.Weight = *weight + } + return DB.Model(&Ability{}).Where("tag = ?", tag).Updates(ability).Error +} + +var fixLock = sync.Mutex{} + +func FixAbility() (int, int, error) { + lock := fixLock.TryLock() + if !lock { + return 0, 0, errors.New("已经有一个修复任务在运行中,请稍后再试") + } + defer fixLock.Unlock() + + // truncate abilities table + if common.UsingSQLite { + err := DB.Exec("DELETE FROM abilities").Error + if err != nil { + common.SysLog(fmt.Sprintf("Delete abilities failed: %s", err.Error())) + return 0, 0, err + } + } else { + err := DB.Exec("TRUNCATE TABLE abilities").Error + if err != nil { + common.SysLog(fmt.Sprintf("Truncate abilities failed: %s", err.Error())) + return 0, 0, err + } + } + var channels []*Channel + // Find all channels + err := DB.Model(&Channel{}).Find(&channels).Error + if err != nil { + return 0, 0, err + } + if len(channels) == 0 { + return 0, 0, nil + } + successCount := 0 + failCount := 0 + for _, chunk := range lo.Chunk(channels, 50) { + ids := lo.Map(chunk, func(c *Channel, _ int) int { return c.Id }) + // Delete all abilities of this channel + err = DB.Where("channel_id IN ?", ids).Delete(&Ability{}).Error + if err != nil { + common.SysLog(fmt.Sprintf("Delete abilities failed: %s", err.Error())) + failCount += len(chunk) + continue + } + // Then add new abilities + for _, channel := range chunk { + err = channel.AddAbilities(nil) + if err != nil { + common.SysLog(fmt.Sprintf("Add abilities for channel %d failed: %s", channel.Id, err.Error())) + failCount++ + } else { + successCount++ + } + } + } + InitChannelCache() + return successCount, failCount, nil +} diff --git a/model/channel.go b/model/channel.go new file mode 100644 index 0000000000000000000000000000000000000000..a7f741b21f5840b84c8b30effe378c82eb3f044f --- /dev/null +++ b/model/channel.go @@ -0,0 +1,1005 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "math/rand" + "strings" + "sync" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/types" + + "github.com/samber/lo" + "gorm.io/gorm" +) + +type Channel struct { + Id int `json:"id"` + Type int `json:"type" gorm:"default:0"` + Key string `json:"key" gorm:"not null"` + OpenAIOrganization *string `json:"openai_organization"` + TestModel *string `json:"test_model"` + Status int `json:"status" gorm:"default:1"` + Name string `json:"name" gorm:"index"` + Weight *uint `json:"weight" gorm:"default:0"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + TestTime int64 `json:"test_time" gorm:"bigint"` + ResponseTime int `json:"response_time"` // in milliseconds + BaseURL *string `json:"base_url" gorm:"column:base_url;default:''"` + Other string `json:"other"` + Balance float64 `json:"balance"` // in USD + BalanceUpdatedTime int64 `json:"balance_updated_time" gorm:"bigint"` + Models string `json:"models"` + Group string `json:"group" gorm:"type:varchar(64);default:'default'"` + UsedQuota int64 `json:"used_quota" gorm:"bigint;default:0"` + ModelMapping *string `json:"model_mapping" gorm:"type:text"` + //MaxInputTokens *int `json:"max_input_tokens" gorm:"default:0"` + StatusCodeMapping *string `json:"status_code_mapping" gorm:"type:varchar(1024);default:''"` + Priority *int64 `json:"priority" gorm:"bigint;default:0"` + AutoBan *int `json:"auto_ban" gorm:"default:1"` + OtherInfo string `json:"other_info"` + Tag *string `json:"tag" gorm:"index"` + Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置 + ParamOverride *string `json:"param_override" gorm:"type:text"` + HeaderOverride *string `json:"header_override" gorm:"type:text"` + Remark *string `json:"remark" gorm:"type:varchar(255)" validate:"max=255"` + // add after v0.8.5 + ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"` + + OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置,存储azure版本等不需要检索的信息,详见dto.ChannelOtherSettings + + // cache info + Keys []string `json:"-" gorm:"-"` +} + +type ChannelInfo struct { + IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式 + MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量 + MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表,key index -> status + MultiKeyDisabledReason map[int]string `json:"multi_key_disabled_reason,omitempty"` // key禁用原因列表,key index -> reason + MultiKeyDisabledTime map[int]int64 `json:"multi_key_disabled_time,omitempty"` // key禁用时间列表,key index -> time + MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引 + MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` +} + +// Value implements driver.Valuer interface +func (c ChannelInfo) Value() (driver.Value, error) { + return common.Marshal(&c) +} + +// Scan implements sql.Scanner interface +func (c *ChannelInfo) Scan(value interface{}) error { + bytesValue, _ := value.([]byte) + return common.Unmarshal(bytesValue, c) +} + +func (channel *Channel) GetKeys() []string { + if channel.Key == "" { + return []string{} + } + if len(channel.Keys) > 0 { + return channel.Keys + } + trimmed := strings.TrimSpace(channel.Key) + // If the key starts with '[', try to parse it as a JSON array (e.g., for Vertex AI scenarios) + if strings.HasPrefix(trimmed, "[") { + var arr []json.RawMessage + if err := common.Unmarshal([]byte(trimmed), &arr); err == nil { + res := make([]string, len(arr)) + for i, v := range arr { + res[i] = string(v) + } + return res + } + } + // Otherwise, fall back to splitting by newline + keys := strings.Split(strings.Trim(channel.Key, "\n"), "\n") + return keys +} + +func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) { + // If not in multi-key mode, return the original key string directly. + if !channel.ChannelInfo.IsMultiKey { + return channel.Key, 0, nil + } + + // Obtain all keys (split by \n) + keys := channel.GetKeys() + if len(keys) == 0 { + // No keys available, return error, should disable the channel + return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey) + } + + lock := GetChannelPollingLock(channel.Id) + lock.Lock() + defer lock.Unlock() + + statusList := channel.ChannelInfo.MultiKeyStatusList + // helper to get key status, default to enabled when missing + getStatus := func(idx int) int { + if statusList == nil { + return common.ChannelStatusEnabled + } + if status, ok := statusList[idx]; ok { + return status + } + return common.ChannelStatusEnabled + } + + // Collect indexes of enabled keys + enabledIdx := make([]int, 0, len(keys)) + for i := range keys { + if getStatus(i) == common.ChannelStatusEnabled { + enabledIdx = append(enabledIdx, i) + } + } + // If no specific status list or none enabled, return an explicit error so caller can + // properly handle a channel with no available keys (e.g. mark channel disabled). + // Returning the first key here caused requests to keep using an already-disabled key. + if len(enabledIdx) == 0 { + return "", 0, types.NewError(errors.New("no enabled keys"), types.ErrorCodeChannelNoAvailableKey) + } + + switch channel.ChannelInfo.MultiKeyMode { + case constant.MultiKeyModeRandom: + // Randomly pick one enabled key + selectedIdx := enabledIdx[rand.Intn(len(enabledIdx))] + return keys[selectedIdx], selectedIdx, nil + case constant.MultiKeyModePolling: + // Use channel-specific lock to ensure thread-safe polling + + channelInfo, err := CacheGetChannelInfo(channel.Id) + if err != nil { + return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) + } + //println("before polling index:", channel.ChannelInfo.MultiKeyPollingIndex) + defer func() { + if common.DebugEnabled { + println(fmt.Sprintf("channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex)) + } + if !common.MemoryCacheEnabled { + _ = channel.SaveChannelInfo() + } else { + // CacheUpdateChannel(channel) + } + }() + // Start from the saved polling index and look for the next enabled key + start := channelInfo.MultiKeyPollingIndex + if start < 0 || start >= len(keys) { + start = 0 + } + for i := 0; i < len(keys); i++ { + idx := (start + i) % len(keys) + if getStatus(idx) == common.ChannelStatusEnabled { + // update polling index for next call (point to the next position) + channel.ChannelInfo.MultiKeyPollingIndex = (idx + 1) % len(keys) + return keys[idx], idx, nil + } + } + // Fallback – should not happen, but return first enabled key + return keys[enabledIdx[0]], enabledIdx[0], nil + default: + // Unknown mode, default to first enabled key (or original key string) + return keys[enabledIdx[0]], enabledIdx[0], nil + } +} + +func (channel *Channel) SaveChannelInfo() error { + return DB.Model(channel).Update("channel_info", channel.ChannelInfo).Error +} + +func (channel *Channel) GetModels() []string { + if channel.Models == "" { + return []string{} + } + return strings.Split(strings.Trim(channel.Models, ","), ",") +} + +func (channel *Channel) GetGroups() []string { + if channel.Group == "" { + return []string{} + } + groups := strings.Split(strings.Trim(channel.Group, ","), ",") + for i, group := range groups { + groups[i] = strings.TrimSpace(group) + } + return groups +} + +func (channel *Channel) GetOtherInfo() map[string]interface{} { + otherInfo := make(map[string]interface{}) + if channel.OtherInfo != "" { + err := common.Unmarshal([]byte(channel.OtherInfo), &otherInfo) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal other info: channel_id=%d, tag=%s, name=%s, error=%v", channel.Id, channel.GetTag(), channel.Name, err)) + } + } + return otherInfo +} + +func (channel *Channel) SetOtherInfo(otherInfo map[string]interface{}) { + otherInfoBytes, err := json.Marshal(otherInfo) + if err != nil { + common.SysLog(fmt.Sprintf("failed to marshal other info: channel_id=%d, tag=%s, name=%s, error=%v", channel.Id, channel.GetTag(), channel.Name, err)) + return + } + channel.OtherInfo = string(otherInfoBytes) +} + +func (channel *Channel) GetTag() string { + if channel.Tag == nil { + return "" + } + return *channel.Tag +} + +func (channel *Channel) SetTag(tag string) { + channel.Tag = &tag +} + +func (channel *Channel) GetAutoBan() bool { + if channel.AutoBan == nil { + return false + } + return *channel.AutoBan == 1 +} + +func (channel *Channel) Save() error { + return DB.Save(channel).Error +} + +func (channel *Channel) SaveWithoutKey() error { + return DB.Omit("key").Save(channel).Error +} + +func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Channel, error) { + var channels []*Channel + var err error + order := "priority desc" + if idSort { + order = "id desc" + } + if selectAll { + err = DB.Order(order).Find(&channels).Error + } else { + err = DB.Order(order).Limit(num).Offset(startIdx).Omit("key").Find(&channels).Error + } + return channels, err +} + +func GetChannelsByTag(tag string, idSort bool, selectAll bool) ([]*Channel, error) { + var channels []*Channel + order := "priority desc" + if idSort { + order = "id desc" + } + query := DB.Where("tag = ?", tag).Order(order) + if !selectAll { + query = query.Omit("key") + } + err := query.Find(&channels).Error + return channels, err +} + +func SearchChannels(keyword string, group string, model string, idSort bool) ([]*Channel, error) { + var channels []*Channel + modelsCol := "`models`" + + // 如果是 PostgreSQL,使用双引号 + if common.UsingPostgreSQL { + modelsCol = `"models"` + } + + baseURLCol := "`base_url`" + // 如果是 PostgreSQL,使用双引号 + if common.UsingPostgreSQL { + baseURLCol = `"base_url"` + } + + order := "priority desc" + if idSort { + order = "id desc" + } + + // 构造基础查询 + baseQuery := DB.Model(&Channel{}).Omit("key") + + // 构造WHERE子句 + var whereClause string + var args []interface{} + if group != "" && group != "null" { + var groupCondition string + if common.UsingMySQL { + groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?` + } else { + // sqlite, PostgreSQL + groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?` + } + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition + args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%") + } else { + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?" + args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%") + } + + // 执行查询 + err := baseQuery.Where(whereClause, args...).Order(order).Find(&channels).Error + if err != nil { + return nil, err + } + return channels, nil +} + +func GetChannelById(id int, selectAll bool) (*Channel, error) { + channel := &Channel{Id: id} + var err error = nil + if selectAll { + err = DB.First(channel, "id = ?", id).Error + } else { + err = DB.Omit("key").First(channel, "id = ?", id).Error + } + if err != nil { + return nil, err + } + if channel == nil { + return nil, errors.New("channel not found") + } + return channel, nil +} + +func BatchInsertChannels(channels []Channel) error { + if len(channels) == 0 { + return nil + } + tx := DB.Begin() + if tx.Error != nil { + return tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + for _, chunk := range lo.Chunk(channels, 50) { + if err := tx.Create(&chunk).Error; err != nil { + tx.Rollback() + return err + } + for _, channel_ := range chunk { + if err := channel_.AddAbilities(tx); err != nil { + tx.Rollback() + return err + } + } + } + return tx.Commit().Error +} + +func BatchDeleteChannels(ids []int) error { + if len(ids) == 0 { + return nil + } + // 使用事务 分批删除channel表和abilities表 + tx := DB.Begin() + if tx.Error != nil { + return tx.Error + } + for _, chunk := range lo.Chunk(ids, 200) { + if err := tx.Where("id in (?)", chunk).Delete(&Channel{}).Error; err != nil { + tx.Rollback() + return err + } + if err := tx.Where("channel_id in (?)", chunk).Delete(&Ability{}).Error; err != nil { + tx.Rollback() + return err + } + } + return tx.Commit().Error +} + +func (channel *Channel) GetPriority() int64 { + if channel.Priority == nil { + return 0 + } + return *channel.Priority +} + +func (channel *Channel) GetWeight() int { + if channel.Weight == nil { + return 0 + } + return int(*channel.Weight) +} + +func (channel *Channel) GetBaseURL() string { + if channel.BaseURL == nil { + return "" + } + url := *channel.BaseURL + if url == "" { + url = constant.ChannelBaseURLs[channel.Type] + } + return url +} + +func (channel *Channel) GetModelMapping() string { + if channel.ModelMapping == nil { + return "" + } + return *channel.ModelMapping +} + +func (channel *Channel) GetStatusCodeMapping() string { + if channel.StatusCodeMapping == nil { + return "" + } + return *channel.StatusCodeMapping +} + +func (channel *Channel) Insert() error { + var err error + err = DB.Create(channel).Error + if err != nil { + return err + } + err = channel.AddAbilities(nil) + return err +} + +func (channel *Channel) Update() error { + // If this is a multi-key channel, recalculate MultiKeySize based on the current key list to avoid inconsistency after editing keys + if channel.ChannelInfo.IsMultiKey { + var keyStr string + if channel.Key != "" { + keyStr = channel.Key + } else { + // If key is not provided, read the existing key from the database + if existing, err := GetChannelById(channel.Id, true); err == nil { + keyStr = existing.Key + } + } + // Parse the key list (supports newline separation or JSON array) + keys := []string{} + if keyStr != "" { + trimmed := strings.TrimSpace(keyStr) + if strings.HasPrefix(trimmed, "[") { + var arr []json.RawMessage + if err := common.Unmarshal([]byte(trimmed), &arr); err == nil { + keys = make([]string, len(arr)) + for i, v := range arr { + keys[i] = string(v) + } + } + } + if len(keys) == 0 { // fallback to newline split + keys = strings.Split(strings.Trim(keyStr, "\n"), "\n") + } + } + channel.ChannelInfo.MultiKeySize = len(keys) + // Clean up status data that exceeds the new key count to prevent index out of range + if channel.ChannelInfo.MultiKeyStatusList != nil { + for idx := range channel.ChannelInfo.MultiKeyStatusList { + if idx >= channel.ChannelInfo.MultiKeySize { + delete(channel.ChannelInfo.MultiKeyStatusList, idx) + } + } + } + } + var err error + err = DB.Model(channel).Updates(channel).Error + if err != nil { + return err + } + DB.Model(channel).First(channel, "id = ?", channel.Id) + err = channel.UpdateAbilities(nil) + return err +} + +func (channel *Channel) UpdateResponseTime(responseTime int64) { + err := DB.Model(channel).Select("response_time", "test_time").Updates(Channel{ + TestTime: common.GetTimestamp(), + ResponseTime: int(responseTime), + }).Error + if err != nil { + common.SysLog(fmt.Sprintf("failed to update response time: channel_id=%d, error=%v", channel.Id, err)) + } +} + +func (channel *Channel) UpdateBalance(balance float64) { + err := DB.Model(channel).Select("balance_updated_time", "balance").Updates(Channel{ + BalanceUpdatedTime: common.GetTimestamp(), + Balance: balance, + }).Error + if err != nil { + common.SysLog(fmt.Sprintf("failed to update balance: channel_id=%d, error=%v", channel.Id, err)) + } +} + +func (channel *Channel) Delete() error { + var err error + err = DB.Delete(channel).Error + if err != nil { + return err + } + err = channel.DeleteAbilities() + return err +} + +var channelStatusLock sync.Mutex + +// channelPollingLocks stores locks for each channel.id to ensure thread-safe polling +var channelPollingLocks sync.Map + +// GetChannelPollingLock returns or creates a mutex for the given channel ID +func GetChannelPollingLock(channelId int) *sync.Mutex { + if lock, exists := channelPollingLocks.Load(channelId); exists { + return lock.(*sync.Mutex) + } + // Create new lock for this channel + newLock := &sync.Mutex{} + actual, _ := channelPollingLocks.LoadOrStore(channelId, newLock) + return actual.(*sync.Mutex) +} + +// CleanupChannelPollingLocks removes locks for channels that no longer exist +// This is optional and can be called periodically to prevent memory leaks +func CleanupChannelPollingLocks() { + var activeChannelIds []int + DB.Model(&Channel{}).Pluck("id", &activeChannelIds) + + activeChannelSet := make(map[int]bool) + for _, id := range activeChannelIds { + activeChannelSet[id] = true + } + + channelPollingLocks.Range(func(key, value interface{}) bool { + channelId := key.(int) + if !activeChannelSet[channelId] { + channelPollingLocks.Delete(channelId) + } + return true + }) +} + +func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason string) { + keys := channel.GetKeys() + if len(keys) == 0 { + channel.Status = status + } else { + var keyIndex int + for i, key := range keys { + if key == usingKey { + keyIndex = i + break + } + } + if channel.ChannelInfo.MultiKeyStatusList == nil { + channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) + } + if status == common.ChannelStatusEnabled { + delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex) + } else { + channel.ChannelInfo.MultiKeyStatusList[keyIndex] = status + if channel.ChannelInfo.MultiKeyDisabledReason == nil { + channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) + } + if channel.ChannelInfo.MultiKeyDisabledTime == nil { + channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) + } + channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = reason + channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp() + } + if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize { + channel.Status = common.ChannelStatusAutoDisabled + info := channel.GetOtherInfo() + info["status_reason"] = "All keys are disabled" + info["status_time"] = common.GetTimestamp() + channel.SetOtherInfo(info) + } + } +} + +func UpdateChannelStatus(channelId int, usingKey string, status int, reason string) bool { + if common.MemoryCacheEnabled { + channelStatusLock.Lock() + defer channelStatusLock.Unlock() + + channelCache, _ := CacheGetChannel(channelId) + if channelCache == nil { + return false + } + if channelCache.ChannelInfo.IsMultiKey { + // Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey + pollingLock := GetChannelPollingLock(channelId) + pollingLock.Lock() + // 如果是多Key模式,更新缓存中的状态 + handlerMultiKeyUpdate(channelCache, usingKey, status, reason) + pollingLock.Unlock() + //CacheUpdateChannel(channelCache) + //return true + } else { + // 如果缓存渠道存在,且状态已是目标状态,直接返回 + if channelCache.Status == status { + return false + } + CacheUpdateChannelStatus(channelId, status) + } + } + + shouldUpdateAbilities := false + defer func() { + if shouldUpdateAbilities { + err := UpdateAbilityStatus(channelId, status == common.ChannelStatusEnabled) + if err != nil { + common.SysLog(fmt.Sprintf("failed to update ability status: channel_id=%d, error=%v", channelId, err)) + } + } + }() + channel, err := GetChannelById(channelId, true) + if err != nil { + return false + } else { + if channel.Status == status { + return false + } + + if channel.ChannelInfo.IsMultiKey { + beforeStatus := channel.Status + // Protect map writes with the same per-channel lock used by readers + pollingLock := GetChannelPollingLock(channelId) + pollingLock.Lock() + handlerMultiKeyUpdate(channel, usingKey, status, reason) + pollingLock.Unlock() + if beforeStatus != channel.Status { + shouldUpdateAbilities = true + } + } else { + info := channel.GetOtherInfo() + info["status_reason"] = reason + info["status_time"] = common.GetTimestamp() + channel.SetOtherInfo(info) + channel.Status = status + shouldUpdateAbilities = true + } + err = channel.SaveWithoutKey() + if err != nil { + common.SysLog(fmt.Sprintf("failed to update channel status: channel_id=%d, status=%d, error=%v", channel.Id, status, err)) + return false + } + } + return true +} + +func EnableChannelByTag(tag string) error { + err := DB.Model(&Channel{}).Where("tag = ?", tag).Update("status", common.ChannelStatusEnabled).Error + if err != nil { + return err + } + err = UpdateAbilityStatusByTag(tag, true) + return err +} + +func DisableChannelByTag(tag string) error { + err := DB.Model(&Channel{}).Where("tag = ?", tag).Update("status", common.ChannelStatusManuallyDisabled).Error + if err != nil { + return err + } + err = UpdateAbilityStatusByTag(tag, false) + return err +} + +func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *string, group *string, priority *int64, weight *uint, paramOverride *string, headerOverride *string) error { + updateData := Channel{} + shouldReCreateAbilities := false + updatedTag := tag + // 如果 newTag 不为空且不等于 tag,则更新 tag + if newTag != nil && *newTag != tag { + updateData.Tag = newTag + updatedTag = *newTag + } + if modelMapping != nil && *modelMapping != "" { + updateData.ModelMapping = modelMapping + } + if models != nil && *models != "" { + shouldReCreateAbilities = true + updateData.Models = *models + } + if group != nil && *group != "" { + shouldReCreateAbilities = true + updateData.Group = *group + } + if priority != nil { + updateData.Priority = priority + } + if weight != nil { + updateData.Weight = weight + } + if paramOverride != nil { + updateData.ParamOverride = paramOverride + } + if headerOverride != nil { + updateData.HeaderOverride = headerOverride + } + + err := DB.Model(&Channel{}).Where("tag = ?", tag).Updates(updateData).Error + if err != nil { + return err + } + if shouldReCreateAbilities { + channels, err := GetChannelsByTag(updatedTag, false, false) + if err == nil { + for _, channel := range channels { + err = channel.UpdateAbilities(nil) + if err != nil { + common.SysLog(fmt.Sprintf("failed to update abilities: channel_id=%d, tag=%s, error=%v", channel.Id, channel.GetTag(), err)) + } + } + } + } else { + err := UpdateAbilityByTag(tag, newTag, priority, weight) + if err != nil { + return err + } + } + return nil +} + +func UpdateChannelUsedQuota(id int, quota int) { + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeChannelUsedQuota, id, quota) + return + } + updateChannelUsedQuota(id, quota) +} + +func updateChannelUsedQuota(id int, quota int) { + err := DB.Model(&Channel{}).Where("id = ?", id).Update("used_quota", gorm.Expr("used_quota + ?", quota)).Error + if err != nil { + common.SysLog(fmt.Sprintf("failed to update channel used quota: channel_id=%d, delta_quota=%d, error=%v", id, quota, err)) + } +} + +func DeleteChannelByStatus(status int64) (int64, error) { + result := DB.Where("status = ?", status).Delete(&Channel{}) + return result.RowsAffected, result.Error +} + +func DeleteDisabledChannel() (int64, error) { + result := DB.Where("status = ? or status = ?", common.ChannelStatusAutoDisabled, common.ChannelStatusManuallyDisabled).Delete(&Channel{}) + return result.RowsAffected, result.Error +} + +func GetPaginatedTags(offset int, limit int) ([]*string, error) { + var tags []*string + err := DB.Model(&Channel{}).Select("DISTINCT tag").Where("tag != ''").Offset(offset).Limit(limit).Find(&tags).Error + return tags, err +} + +func SearchTags(keyword string, group string, model string, idSort bool) ([]*string, error) { + var tags []*string + modelsCol := "`models`" + + // 如果是 PostgreSQL,使用双引号 + if common.UsingPostgreSQL { + modelsCol = `"models"` + } + + baseURLCol := "`base_url`" + // 如果是 PostgreSQL,使用双引号 + if common.UsingPostgreSQL { + baseURLCol = `"base_url"` + } + + order := "priority desc" + if idSort { + order = "id desc" + } + + // 构造基础查询 + baseQuery := DB.Model(&Channel{}).Omit("key") + + // 构造WHERE子句 + var whereClause string + var args []interface{} + if group != "" && group != "null" { + var groupCondition string + if common.UsingMySQL { + groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?` + } else { + // sqlite, PostgreSQL + groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?` + } + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition + args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%") + } else { + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?" + args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%") + } + + subQuery := baseQuery.Where(whereClause, args...). + Select("tag"). + Where("tag != ''"). + Order(order) + + err := DB.Table("(?) as sub", subQuery). + Select("DISTINCT tag"). + Find(&tags).Error + + if err != nil { + return nil, err + } + + return tags, nil +} + +func (channel *Channel) ValidateSettings() error { + channelParams := &dto.ChannelSettings{} + if channel.Setting != nil && *channel.Setting != "" { + err := common.Unmarshal([]byte(*channel.Setting), channelParams) + if err != nil { + return err + } + } + return nil +} + +func (channel *Channel) GetSetting() dto.ChannelSettings { + setting := dto.ChannelSettings{} + if channel.Setting != nil && *channel.Setting != "" { + err := common.Unmarshal([]byte(*channel.Setting), &setting) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal setting: channel_id=%d, error=%v", channel.Id, err)) + channel.Setting = nil // 清空设置以避免后续错误 + _ = channel.Save() // 保存修改 + } + } + return setting +} + +func (channel *Channel) SetSetting(setting dto.ChannelSettings) { + settingBytes, err := common.Marshal(setting) + if err != nil { + common.SysLog(fmt.Sprintf("failed to marshal setting: channel_id=%d, error=%v", channel.Id, err)) + return + } + channel.Setting = common.GetPointer[string](string(settingBytes)) +} + +func (channel *Channel) GetOtherSettings() dto.ChannelOtherSettings { + setting := dto.ChannelOtherSettings{} + if channel.OtherSettings != "" { + err := common.UnmarshalJsonStr(channel.OtherSettings, &setting) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal setting: channel_id=%d, error=%v", channel.Id, err)) + channel.OtherSettings = "{}" // 清空设置以避免后续错误 + _ = channel.Save() // 保存修改 + } + } + return setting +} + +func (channel *Channel) SetOtherSettings(setting dto.ChannelOtherSettings) { + settingBytes, err := common.Marshal(setting) + if err != nil { + common.SysLog(fmt.Sprintf("failed to marshal setting: channel_id=%d, error=%v", channel.Id, err)) + return + } + channel.OtherSettings = string(settingBytes) +} + +func (channel *Channel) GetParamOverride() map[string]interface{} { + paramOverride := make(map[string]interface{}) + if channel.ParamOverride != nil && *channel.ParamOverride != "" { + err := common.Unmarshal([]byte(*channel.ParamOverride), ¶mOverride) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal param override: channel_id=%d, error=%v", channel.Id, err)) + } + } + return paramOverride +} + +func (channel *Channel) GetHeaderOverride() map[string]interface{} { + headerOverride := make(map[string]interface{}) + if channel.HeaderOverride != nil && *channel.HeaderOverride != "" { + err := common.Unmarshal([]byte(*channel.HeaderOverride), &headerOverride) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal header override: channel_id=%d, error=%v", channel.Id, err)) + } + } + return headerOverride +} + +func GetChannelsByIds(ids []int) ([]*Channel, error) { + var channels []*Channel + err := DB.Where("id in (?)", ids).Find(&channels).Error + return channels, err +} + +func BatchSetChannelTag(ids []int, tag *string) error { + // 开启事务 + tx := DB.Begin() + if tx.Error != nil { + return tx.Error + } + + // 更新标签 + err := tx.Model(&Channel{}).Where("id in (?)", ids).Update("tag", tag).Error + if err != nil { + tx.Rollback() + return err + } + + // update ability status + channels, err := GetChannelsByIds(ids) + if err != nil { + tx.Rollback() + return err + } + + for _, channel := range channels { + err = channel.UpdateAbilities(tx) + if err != nil { + tx.Rollback() + return err + } + } + + // 提交事务 + return tx.Commit().Error +} + +// CountAllChannels returns total channels in DB +func CountAllChannels() (int64, error) { + var total int64 + err := DB.Model(&Channel{}).Count(&total).Error + return total, err +} + +// CountAllTags returns number of non-empty distinct tags +func CountAllTags() (int64, error) { + var total int64 + err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error + return total, err +} + +// Get channels of specified type with pagination +func GetChannelsByType(startIdx int, num int, idSort bool, channelType int) ([]*Channel, error) { + var channels []*Channel + order := "priority desc" + if idSort { + order = "id desc" + } + err := DB.Where("type = ?", channelType).Order(order).Limit(num).Offset(startIdx).Omit("key").Find(&channels).Error + return channels, err +} + +// Count channels of specific type +func CountChannelsByType(channelType int) (int64, error) { + var count int64 + err := DB.Model(&Channel{}).Where("type = ?", channelType).Count(&count).Error + return count, err +} + +// Return map[type]count for all channels +func CountChannelsGroupByType() (map[int64]int64, error) { + type result struct { + Type int64 `gorm:"column:type"` + Count int64 `gorm:"column:count"` + } + var results []result + err := DB.Model(&Channel{}).Select("type, count(*) as count").Group("type").Find(&results).Error + if err != nil { + return nil, err + } + counts := make(map[int64]int64) + for _, r := range results { + counts[r.Type] = r.Count + } + return counts, nil +} diff --git a/model/channel_cache.go b/model/channel_cache.go new file mode 100644 index 0000000000000000000000000000000000000000..c9c503576038c9090b99174179212495564d6ce0 --- /dev/null +++ b/model/channel_cache.go @@ -0,0 +1,265 @@ +package model + +import ( + "errors" + "fmt" + "math/rand" + "sort" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/setting/ratio_setting" +) + +var group2model2channels map[string]map[string][]int // enabled channel +var channelsIDM map[int]*Channel // all channels include disabled +var channelSyncLock sync.RWMutex + +func InitChannelCache() { + if !common.MemoryCacheEnabled { + return + } + newChannelId2channel := make(map[int]*Channel) + var channels []*Channel + DB.Find(&channels) + for _, channel := range channels { + newChannelId2channel[channel.Id] = channel + } + var abilities []*Ability + DB.Find(&abilities) + groups := make(map[string]bool) + for _, ability := range abilities { + groups[ability.Group] = true + } + newGroup2model2channels := make(map[string]map[string][]int) + for group := range groups { + newGroup2model2channels[group] = make(map[string][]int) + } + for _, channel := range channels { + if channel.Status != common.ChannelStatusEnabled { + continue // skip disabled channels + } + groups := strings.Split(channel.Group, ",") + for _, group := range groups { + models := strings.Split(channel.Models, ",") + for _, model := range models { + if _, ok := newGroup2model2channels[group][model]; !ok { + newGroup2model2channels[group][model] = make([]int, 0) + } + newGroup2model2channels[group][model] = append(newGroup2model2channels[group][model], channel.Id) + } + } + } + + // sort by priority + for group, model2channels := range newGroup2model2channels { + for model, channels := range model2channels { + sort.Slice(channels, func(i, j int) bool { + return newChannelId2channel[channels[i]].GetPriority() > newChannelId2channel[channels[j]].GetPriority() + }) + newGroup2model2channels[group][model] = channels + } + } + + channelSyncLock.Lock() + group2model2channels = newGroup2model2channels + //channelsIDM = newChannelId2channel + for i, channel := range newChannelId2channel { + if channel.ChannelInfo.IsMultiKey { + channel.Keys = channel.GetKeys() + if channel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling { + if oldChannel, ok := channelsIDM[i]; ok { + // 存在旧的渠道,如果是多key且轮询,保留轮询索引信息 + if oldChannel.ChannelInfo.IsMultiKey && oldChannel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling { + channel.ChannelInfo.MultiKeyPollingIndex = oldChannel.ChannelInfo.MultiKeyPollingIndex + } + } + } + } + } + channelsIDM = newChannelId2channel + channelSyncLock.Unlock() + common.SysLog("channels synced from database") +} + +func SyncChannelCache(frequency int) { + for { + time.Sleep(time.Duration(frequency) * time.Second) + common.SysLog("syncing channels from database") + InitChannelCache() + } +} + +func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { + // if memory cache is disabled, get channel directly from database + if !common.MemoryCacheEnabled { + return GetChannel(group, model, retry) + } + + channelSyncLock.RLock() + defer channelSyncLock.RUnlock() + + // First, try to find channels with the exact model name. + channels := group2model2channels[group][model] + + // If no channels found, try to find channels with the normalized model name. + if len(channels) == 0 { + normalizedModel := ratio_setting.FormatMatchingModelName(model) + channels = group2model2channels[group][normalizedModel] + } + + if len(channels) == 0 { + return nil, nil + } + + if len(channels) == 1 { + if channel, ok := channelsIDM[channels[0]]; ok { + return channel, nil + } + return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channels[0]) + } + + uniquePriorities := make(map[int]bool) + for _, channelId := range channels { + if channel, ok := channelsIDM[channelId]; ok { + uniquePriorities[int(channel.GetPriority())] = true + } else { + return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId) + } + } + var sortedUniquePriorities []int + for priority := range uniquePriorities { + sortedUniquePriorities = append(sortedUniquePriorities, priority) + } + sort.Sort(sort.Reverse(sort.IntSlice(sortedUniquePriorities))) + + if retry >= len(uniquePriorities) { + retry = len(uniquePriorities) - 1 + } + targetPriority := int64(sortedUniquePriorities[retry]) + + // get the priority for the given retry number + var sumWeight = 0 + var targetChannels []*Channel + for _, channelId := range channels { + if channel, ok := channelsIDM[channelId]; ok { + if channel.GetPriority() == targetPriority { + sumWeight += channel.GetWeight() + targetChannels = append(targetChannels, channel) + } + } else { + return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId) + } + } + + if len(targetChannels) == 0 { + return nil, errors.New(fmt.Sprintf("no channel found, group: %s, model: %s, priority: %d", group, model, targetPriority)) + } + + // smoothing factor and adjustment + smoothingFactor := 1 + smoothingAdjustment := 0 + + if sumWeight == 0 { + // when all channels have weight 0, set sumWeight to the number of channels and set smoothing adjustment to 100 + // each channel's effective weight = 100 + sumWeight = len(targetChannels) * 100 + smoothingAdjustment = 100 + } else if sumWeight/len(targetChannels) < 10 { + // when the average weight is less than 10, set smoothing factor to 100 + smoothingFactor = 100 + } + + // Calculate the total weight of all channels up to endIdx + totalWeight := sumWeight * smoothingFactor + + // Generate a random value in the range [0, totalWeight) + randomWeight := rand.Intn(totalWeight) + + // Find a channel based on its weight + for _, channel := range targetChannels { + randomWeight -= channel.GetWeight()*smoothingFactor + smoothingAdjustment + if randomWeight < 0 { + return channel, nil + } + } + // return null if no channel is not found + return nil, errors.New("channel not found") +} + +func CacheGetChannel(id int) (*Channel, error) { + if !common.MemoryCacheEnabled { + return GetChannelById(id, true) + } + channelSyncLock.RLock() + defer channelSyncLock.RUnlock() + + c, ok := channelsIDM[id] + if !ok { + return nil, fmt.Errorf("渠道# %d,已不存在", id) + } + return c, nil +} + +func CacheGetChannelInfo(id int) (*ChannelInfo, error) { + if !common.MemoryCacheEnabled { + channel, err := GetChannelById(id, true) + if err != nil { + return nil, err + } + return &channel.ChannelInfo, nil + } + channelSyncLock.RLock() + defer channelSyncLock.RUnlock() + + c, ok := channelsIDM[id] + if !ok { + return nil, fmt.Errorf("渠道# %d,已不存在", id) + } + return &c.ChannelInfo, nil +} + +func CacheUpdateChannelStatus(id int, status int) { + if !common.MemoryCacheEnabled { + return + } + channelSyncLock.Lock() + defer channelSyncLock.Unlock() + if channel, ok := channelsIDM[id]; ok { + channel.Status = status + } + if status != common.ChannelStatusEnabled { + // delete the channel from group2model2channels + for group, model2channels := range group2model2channels { + for model, channels := range model2channels { + for i, channelId := range channels { + if channelId == id { + // remove the channel from the slice + group2model2channels[group][model] = append(channels[:i], channels[i+1:]...) + break + } + } + } + } + } +} + +func CacheUpdateChannel(channel *Channel) { + if !common.MemoryCacheEnabled { + return + } + channelSyncLock.Lock() + defer channelSyncLock.Unlock() + if channel == nil { + return + } + + println("CacheUpdateChannel:", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex) + + println("before:", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex) + channelsIDM[channel.Id] = channel + println("after :", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex) +} diff --git a/model/log.go b/model/log.go new file mode 100644 index 0000000000000000000000000000000000000000..7495d647d0aaee7ce104f03397ae5fba3a129f0c --- /dev/null +++ b/model/log.go @@ -0,0 +1,411 @@ +package model + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" + + "github.com/bytedance/gopkg/util/gopool" + "gorm.io/gorm" +) + +type Log struct { + Id int `json:"id" gorm:"index:idx_created_at_id,priority:1"` + UserId int `json:"user_id" gorm:"index"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"` + Type int `json:"type" gorm:"index:idx_created_at_type"` + Content string `json:"content"` + Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"` + TokenName string `json:"token_name" gorm:"index;default:''"` + ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` + Quota int `json:"quota" gorm:"default:0"` + PromptTokens int `json:"prompt_tokens" gorm:"default:0"` + CompletionTokens int `json:"completion_tokens" gorm:"default:0"` + UseTime int `json:"use_time" gorm:"default:0"` + IsStream bool `json:"is_stream"` + ChannelId int `json:"channel" gorm:"index"` + ChannelName string `json:"channel_name" gorm:"->"` + TokenId int `json:"token_id" gorm:"default:0;index"` + Group string `json:"group" gorm:"index"` + Ip string `json:"ip" gorm:"index;default:''"` + Other string `json:"other"` +} + +// don't use iota, avoid change log type value +const ( + LogTypeUnknown = 0 + LogTypeTopup = 1 + LogTypeConsume = 2 + LogTypeManage = 3 + LogTypeSystem = 4 + LogTypeError = 5 + LogTypeRefund = 6 +) + +func formatUserLogs(logs []*Log) { + for i := range logs { + logs[i].ChannelName = "" + var otherMap map[string]interface{} + otherMap, _ = common.StrToMap(logs[i].Other) + if otherMap != nil { + // delete admin + delete(otherMap, "admin_info") + } + logs[i].Other = common.MapToJsonStr(otherMap) + logs[i].Id = logs[i].Id % 1024 + } +} + +func GetLogByKey(key string) (logs []*Log, err error) { + if os.Getenv("LOG_SQL_DSN") != "" { + var tk Token + if err = DB.Model(&Token{}).Where(logKeyCol+"=?", strings.TrimPrefix(key, "sk-")).First(&tk).Error; err != nil { + return nil, err + } + err = LOG_DB.Model(&Log{}).Where("token_id=?", tk.Id).Find(&logs).Error + } else { + err = LOG_DB.Joins("left join tokens on tokens.id = logs.token_id").Where("tokens.key = ?", strings.TrimPrefix(key, "sk-")).Find(&logs).Error + } + formatUserLogs(logs) + return logs, err +} + +func RecordLog(userId int, logType int, content string) { + if logType == LogTypeConsume && !common.LogConsumeEnabled { + return + } + username, _ := GetUsernameById(userId, false) + log := &Log{ + UserId: userId, + Username: username, + CreatedAt: common.GetTimestamp(), + Type: logType, + Content: content, + } + err := LOG_DB.Create(log).Error + if err != nil { + common.SysLog("failed to record log: " + err.Error()) + } +} + +func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int, + isStream bool, group string, other map[string]interface{}) { + logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content)) + username := c.GetString("username") + otherStr := common.MapToJsonStr(other) + // 判断是否需要记录 IP + needRecordIp := false + if settingMap, err := GetUserSetting(userId, false); err == nil { + if settingMap.RecordIpLog { + needRecordIp = true + } + } + log := &Log{ + UserId: userId, + Username: username, + CreatedAt: common.GetTimestamp(), + Type: LogTypeError, + Content: content, + PromptTokens: 0, + CompletionTokens: 0, + TokenName: tokenName, + ModelName: modelName, + Quota: 0, + ChannelId: channelId, + TokenId: tokenId, + UseTime: useTimeSeconds, + IsStream: isStream, + Group: group, + Ip: func() string { + if needRecordIp { + return c.ClientIP() + } + return "" + }(), + Other: otherStr, + } + err := LOG_DB.Create(log).Error + if err != nil { + logger.LogError(c, "failed to record log: "+err.Error()) + } +} + +type RecordConsumeLogParams struct { + ChannelId int `json:"channel_id"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + ModelName string `json:"model_name"` + TokenName string `json:"token_name"` + Quota int `json:"quota"` + Content string `json:"content"` + TokenId int `json:"token_id"` + UseTimeSeconds int `json:"use_time_seconds"` + IsStream bool `json:"is_stream"` + Group string `json:"group"` + Other map[string]interface{} `json:"other"` +} + +func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) { + if !common.LogConsumeEnabled { + return + } + logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params))) + username := c.GetString("username") + otherStr := common.MapToJsonStr(params.Other) + // 判断是否需要记录 IP + needRecordIp := false + if settingMap, err := GetUserSetting(userId, false); err == nil { + if settingMap.RecordIpLog { + needRecordIp = true + } + } + log := &Log{ + UserId: userId, + Username: username, + CreatedAt: common.GetTimestamp(), + Type: LogTypeConsume, + Content: params.Content, + PromptTokens: params.PromptTokens, + CompletionTokens: params.CompletionTokens, + TokenName: params.TokenName, + ModelName: params.ModelName, + Quota: params.Quota, + ChannelId: params.ChannelId, + TokenId: params.TokenId, + UseTime: params.UseTimeSeconds, + IsStream: params.IsStream, + Group: params.Group, + Ip: func() string { + if needRecordIp { + return c.ClientIP() + } + return "" + }(), + Other: otherStr, + } + err := LOG_DB.Create(log).Error + if err != nil { + logger.LogError(c, "failed to record log: "+err.Error()) + } + if common.DataExportEnabled { + gopool.Go(func() { + LogQuotaData(userId, username, params.ModelName, params.Quota, common.GetTimestamp(), params.PromptTokens+params.CompletionTokens) + }) + } +} + +func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string) (logs []*Log, total int64, err error) { + var tx *gorm.DB + if logType == LogTypeUnknown { + tx = LOG_DB + } else { + tx = LOG_DB.Where("logs.type = ?", logType) + } + + if modelName != "" { + tx = tx.Where("logs.model_name like ?", modelName) + } + if username != "" { + tx = tx.Where("logs.username = ?", username) + } + if tokenName != "" { + tx = tx.Where("logs.token_name = ?", tokenName) + } + if startTimestamp != 0 { + tx = tx.Where("logs.created_at >= ?", startTimestamp) + } + if endTimestamp != 0 { + tx = tx.Where("logs.created_at <= ?", endTimestamp) + } + if channel != 0 { + tx = tx.Where("logs.channel_id = ?", channel) + } + if group != "" { + tx = tx.Where("logs."+logGroupCol+" = ?", group) + } + err = tx.Model(&Log{}).Count(&total).Error + if err != nil { + return nil, 0, err + } + err = tx.Order("logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error + if err != nil { + return nil, 0, err + } + + channelIds := types.NewSet[int]() + for _, log := range logs { + if log.ChannelId != 0 { + channelIds.Add(log.ChannelId) + } + } + + if channelIds.Len() > 0 { + var channels []struct { + Id int `gorm:"column:id"` + Name string `gorm:"column:name"` + } + if err = DB.Table("channels").Select("id, name").Where("id IN ?", channelIds.Items()).Find(&channels).Error; err != nil { + return logs, total, err + } + channelMap := make(map[int]string, len(channels)) + for _, channel := range channels { + channelMap[channel.Id] = channel.Name + } + for i := range logs { + logs[i].ChannelName = channelMap[logs[i].ChannelId] + } + } + + return logs, total, err +} + +func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string) (logs []*Log, total int64, err error) { + var tx *gorm.DB + if logType == LogTypeUnknown { + tx = LOG_DB.Where("logs.user_id = ?", userId) + } else { + tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType) + } + + if modelName != "" { + tx = tx.Where("logs.model_name like ?", modelName) + } + if tokenName != "" { + tx = tx.Where("logs.token_name = ?", tokenName) + } + if startTimestamp != 0 { + tx = tx.Where("logs.created_at >= ?", startTimestamp) + } + if endTimestamp != 0 { + tx = tx.Where("logs.created_at <= ?", endTimestamp) + } + if group != "" { + tx = tx.Where("logs."+logGroupCol+" = ?", group) + } + err = tx.Model(&Log{}).Count(&total).Error + if err != nil { + return nil, 0, err + } + err = tx.Order("logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error + if err != nil { + return nil, 0, err + } + + formatUserLogs(logs) + return logs, total, err +} + +func SearchAllLogs(keyword string) (logs []*Log, err error) { + err = LOG_DB.Where("type = ? or content LIKE ?", keyword, keyword+"%").Order("id desc").Limit(common.MaxRecentItems).Find(&logs).Error + return logs, err +} + +func SearchUserLogs(userId int, keyword string) (logs []*Log, err error) { + err = LOG_DB.Where("user_id = ? and type = ?", userId, keyword).Order("id desc").Limit(common.MaxRecentItems).Find(&logs).Error + formatUserLogs(logs) + return logs, err +} + +type Stat struct { + Quota int `json:"quota"` + Rpm int `json:"rpm"` + Tpm int `json:"tpm"` +} + +func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string) (stat Stat) { + tx := LOG_DB.Table("logs").Select("sum(quota) quota") + + // 为rpm和tpm创建单独的查询 + rpmTpmQuery := LOG_DB.Table("logs").Select("count(*) rpm, sum(prompt_tokens) + sum(completion_tokens) tpm") + + if username != "" { + tx = tx.Where("username = ?", username) + rpmTpmQuery = rpmTpmQuery.Where("username = ?", username) + } + if tokenName != "" { + tx = tx.Where("token_name = ?", tokenName) + rpmTpmQuery = rpmTpmQuery.Where("token_name = ?", tokenName) + } + if startTimestamp != 0 { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if endTimestamp != 0 { + tx = tx.Where("created_at <= ?", endTimestamp) + } + if modelName != "" { + tx = tx.Where("model_name like ?", modelName) + rpmTpmQuery = rpmTpmQuery.Where("model_name like ?", modelName) + } + if channel != 0 { + tx = tx.Where("channel_id = ?", channel) + rpmTpmQuery = rpmTpmQuery.Where("channel_id = ?", channel) + } + if group != "" { + tx = tx.Where(logGroupCol+" = ?", group) + rpmTpmQuery = rpmTpmQuery.Where(logGroupCol+" = ?", group) + } + + tx = tx.Where("type = ?", LogTypeConsume) + rpmTpmQuery = rpmTpmQuery.Where("type = ?", LogTypeConsume) + + // 只统计最近60秒的rpm和tpm + rpmTpmQuery = rpmTpmQuery.Where("created_at >= ?", time.Now().Add(-60*time.Second).Unix()) + + // 执行查询 + tx.Scan(&stat) + rpmTpmQuery.Scan(&stat) + + return stat +} + +func SumUsedToken(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string) (token int) { + tx := LOG_DB.Table("logs").Select("ifnull(sum(prompt_tokens),0) + ifnull(sum(completion_tokens),0)") + if username != "" { + tx = tx.Where("username = ?", username) + } + if tokenName != "" { + tx = tx.Where("token_name = ?", tokenName) + } + if startTimestamp != 0 { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if endTimestamp != 0 { + tx = tx.Where("created_at <= ?", endTimestamp) + } + if modelName != "" { + tx = tx.Where("model_name = ?", modelName) + } + tx.Where("type = ?", LogTypeConsume).Scan(&token) + return token +} + +func DeleteOldLog(ctx context.Context, targetTimestamp int64, limit int) (int64, error) { + var total int64 = 0 + + for { + if nil != ctx.Err() { + return total, ctx.Err() + } + + result := LOG_DB.Where("created_at < ?", targetTimestamp).Limit(limit).Delete(&Log{}) + if nil != result.Error { + return total, result.Error + } + + total += result.RowsAffected + + if result.RowsAffected < int64(limit) { + break + } + } + + return total, nil +} diff --git a/model/main.go b/model/main.go new file mode 100644 index 0000000000000000000000000000000000000000..04842f13f5bc40f62e4a6a99785746bb87464c39 --- /dev/null +++ b/model/main.go @@ -0,0 +1,478 @@ +package model + +import ( + "fmt" + "log" + "os" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + + "github.com/glebarez/sqlite" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +var commonGroupCol string +var commonKeyCol string +var commonTrueVal string +var commonFalseVal string + +var logKeyCol string +var logGroupCol string + +func initCol() { + // init common column names + if common.UsingPostgreSQL { + commonGroupCol = `"group"` + commonKeyCol = `"key"` + commonTrueVal = "true" + commonFalseVal = "false" + } else { + commonGroupCol = "`group`" + commonKeyCol = "`key`" + commonTrueVal = "1" + commonFalseVal = "0" + } + if os.Getenv("LOG_SQL_DSN") != "" { + switch common.LogSqlType { + case common.DatabaseTypePostgreSQL: + logGroupCol = `"group"` + logKeyCol = `"key"` + default: + logGroupCol = commonGroupCol + logKeyCol = commonKeyCol + } + } else { + // LOG_SQL_DSN 为空时,日志数据库与主数据库相同 + if common.UsingPostgreSQL { + logGroupCol = `"group"` + logKeyCol = `"key"` + } else { + logGroupCol = commonGroupCol + logKeyCol = commonKeyCol + } + } + // log sql type and database type + //common.SysLog("Using Log SQL Type: " + common.LogSqlType) +} + +var DB *gorm.DB + +var LOG_DB *gorm.DB + +func createRootAccountIfNeed() error { + var user User + //if user.Status != common.UserStatusEnabled { + if err := DB.First(&user).Error; err != nil { + common.SysLog("no user exists, create a root user for you: username is root, password is 123456") + hashedPassword, err := common.Password2Hash("123456") + if err != nil { + return err + } + rootUser := User{ + Username: "root", + Password: hashedPassword, + Role: common.RoleRootUser, + Status: common.UserStatusEnabled, + DisplayName: "Root User", + AccessToken: nil, + Quota: 100000000, + } + DB.Create(&rootUser) + } + return nil +} + +func CheckSetup() { + setup := GetSetup() + if setup == nil { + // No setup record exists, check if we have a root user + if RootUserExists() { + common.SysLog("system is not initialized, but root user exists") + // Create setup record + newSetup := Setup{ + Version: common.Version, + InitializedAt: time.Now().Unix(), + } + err := DB.Create(&newSetup).Error + if err != nil { + common.SysLog("failed to create setup record: " + err.Error()) + } + constant.Setup = true + } else { + common.SysLog("system is not initialized and no root user exists") + constant.Setup = false + } + } else { + // Setup record exists, system is initialized + common.SysLog("system is already initialized at: " + time.Unix(setup.InitializedAt, 0).String()) + constant.Setup = true + } +} + +func chooseDB(envName string, isLog bool) (*gorm.DB, error) { + defer func() { + initCol() + }() + dsn := os.Getenv(envName) + if dsn != "" { + if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { + // Use PostgreSQL + common.SysLog("using PostgreSQL as database") + if !isLog { + common.UsingPostgreSQL = true + } else { + common.LogSqlType = common.DatabaseTypePostgreSQL + } + return gorm.Open(postgres.New(postgres.Config{ + DSN: dsn, + PreferSimpleProtocol: true, // disables implicit prepared statement usage + }), &gorm.Config{ + PrepareStmt: true, // precompile SQL + }) + } + if strings.HasPrefix(dsn, "local") { + common.SysLog("SQL_DSN not set, using SQLite as database") + if !isLog { + common.UsingSQLite = true + } else { + common.LogSqlType = common.DatabaseTypeSQLite + } + return gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{ + PrepareStmt: true, // precompile SQL + }) + } + // Use MySQL + common.SysLog("using MySQL as database") + // check parseTime + if !strings.Contains(dsn, "parseTime") { + if strings.Contains(dsn, "?") { + dsn += "&parseTime=true" + } else { + dsn += "?parseTime=true" + } + } + if !isLog { + common.UsingMySQL = true + } else { + common.LogSqlType = common.DatabaseTypeMySQL + } + return gorm.Open(mysql.Open(dsn), &gorm.Config{ + PrepareStmt: true, // precompile SQL + }) + } + // Use SQLite + common.SysLog("SQL_DSN not set, using SQLite as database") + common.UsingSQLite = true + return gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{ + PrepareStmt: true, // precompile SQL + }) +} + +func InitDB() (err error) { + db, err := chooseDB("SQL_DSN", false) + if err == nil { + if common.DebugEnabled { + db = db.Debug() + } + DB = db + // MySQL charset/collation startup check: ensure Chinese-capable charset + if common.UsingMySQL { + if err := checkMySQLChineseSupport(DB); err != nil { + panic(err) + } + } + sqlDB, err := DB.DB() + if err != nil { + return err + } + sqlDB.SetMaxIdleConns(common.GetEnvOrDefault("SQL_MAX_IDLE_CONNS", 100)) + sqlDB.SetMaxOpenConns(common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000)) + sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60))) + + if !common.IsMasterNode { + return nil + } + if common.UsingMySQL { + //_, _ = sqlDB.Exec("ALTER TABLE channels MODIFY model_mapping TEXT;") // TODO: delete this line when most users have upgraded + } + common.SysLog("database migration started") + err = migrateDB() + return err + } else { + common.FatalLog(err) + } + return err +} + +func InitLogDB() (err error) { + if os.Getenv("LOG_SQL_DSN") == "" { + LOG_DB = DB + return + } + db, err := chooseDB("LOG_SQL_DSN", true) + if err == nil { + if common.DebugEnabled { + db = db.Debug() + } + LOG_DB = db + // If log DB is MySQL, also ensure Chinese-capable charset + if common.LogSqlType == common.DatabaseTypeMySQL { + if err := checkMySQLChineseSupport(LOG_DB); err != nil { + panic(err) + } + } + sqlDB, err := LOG_DB.DB() + if err != nil { + return err + } + sqlDB.SetMaxIdleConns(common.GetEnvOrDefault("SQL_MAX_IDLE_CONNS", 100)) + sqlDB.SetMaxOpenConns(common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000)) + sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60))) + + if !common.IsMasterNode { + return nil + } + common.SysLog("database migration started") + err = migrateLOGDB() + return err + } else { + common.FatalLog(err) + } + return err +} + +func migrateDB() error { + err := DB.AutoMigrate( + &Channel{}, + &Token{}, + &User{}, + &PasskeyCredential{}, + &Option{}, + &Redemption{}, + &Ability{}, + &Log{}, + &Midjourney{}, + &TopUp{}, + &QuotaData{}, + &Task{}, + &Model{}, + &Vendor{}, + &PrefillGroup{}, + &Setup{}, + &TwoFA{}, + &TwoFABackupCode{}, + ) + if err != nil { + return err + } + return nil +} + +func migrateDBFast() error { + + var wg sync.WaitGroup + + migrations := []struct { + model interface{} + name string + }{ + {&Channel{}, "Channel"}, + {&Token{}, "Token"}, + {&User{}, "User"}, + {&PasskeyCredential{}, "PasskeyCredential"}, + {&Option{}, "Option"}, + {&Redemption{}, "Redemption"}, + {&Ability{}, "Ability"}, + {&Log{}, "Log"}, + {&Midjourney{}, "Midjourney"}, + {&TopUp{}, "TopUp"}, + {&QuotaData{}, "QuotaData"}, + {&Task{}, "Task"}, + {&Model{}, "Model"}, + {&Vendor{}, "Vendor"}, + {&PrefillGroup{}, "PrefillGroup"}, + {&Setup{}, "Setup"}, + {&TwoFA{}, "TwoFA"}, + {&TwoFABackupCode{}, "TwoFABackupCode"}, + } + // 动态计算migration数量,确保errChan缓冲区足够大 + errChan := make(chan error, len(migrations)) + + for _, m := range migrations { + wg.Add(1) + go func(model interface{}, name string) { + defer wg.Done() + if err := DB.AutoMigrate(model); err != nil { + errChan <- fmt.Errorf("failed to migrate %s: %v", name, err) + } + }(m.model, m.name) + } + + // Wait for all migrations to complete + wg.Wait() + close(errChan) + + // Check for any errors + for err := range errChan { + if err != nil { + return err + } + } + common.SysLog("database migrated") + return nil +} + +func migrateLOGDB() error { + var err error + if err = LOG_DB.AutoMigrate(&Log{}); err != nil { + return err + } + return nil +} + +func closeDB(db *gorm.DB) error { + sqlDB, err := db.DB() + if err != nil { + return err + } + err = sqlDB.Close() + return err +} + +func CloseDB() error { + if LOG_DB != DB { + err := closeDB(LOG_DB) + if err != nil { + return err + } + } + return closeDB(DB) +} + +// checkMySQLChineseSupport ensures the MySQL connection and current schema +// default charset/collation can store Chinese characters. It allows common +// Chinese-capable charsets (utf8mb4, utf8, gbk, big5, gb18030) and panics otherwise. +func checkMySQLChineseSupport(db *gorm.DB) error { + // 仅检测:当前库默认字符集/排序规则 + 各表的排序规则(隐含字符集) + + // Read current schema defaults + var schemaCharset, schemaCollation string + err := db.Raw("SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()").Row().Scan(&schemaCharset, &schemaCollation) + if err != nil { + return fmt.Errorf("读取当前库默认字符集/排序规则失败 / Failed to read schema default charset/collation: %v", err) + } + + toLower := func(s string) string { return strings.ToLower(s) } + // Allowed charsets that can store Chinese text + allowedCharsets := map[string]string{ + "utf8mb4": "utf8mb4_", + "utf8": "utf8_", + "gbk": "gbk_", + "big5": "big5_", + "gb18030": "gb18030_", + } + isChineseCapable := func(cs, cl string) bool { + csLower := toLower(cs) + clLower := toLower(cl) + if prefix, ok := allowedCharsets[csLower]; ok { + if clLower == "" { + return true + } + return strings.HasPrefix(clLower, prefix) + } + // 如果仅提供了排序规则,尝试按排序规则前缀判断 + for _, prefix := range allowedCharsets { + if strings.HasPrefix(clLower, prefix) { + return true + } + } + return false + } + + // 1) 当前库默认值必须支持中文 + if !isChineseCapable(schemaCharset, schemaCollation) { + return fmt.Errorf("当前库默认字符集/排序规则不支持中文:schema(%s/%s)。请将库设置为 utf8mb4/utf8/gbk/big5/gb18030 / Schema default charset/collation is not Chinese-capable: schema(%s/%s). Please set to utf8mb4/utf8/gbk/big5/gb18030", + schemaCharset, schemaCollation, schemaCharset, schemaCollation) + } + + // 2) 所有物理表的排序规则(隐含字符集)必须支持中文 + type tableInfo struct { + Name string + Collation *string + } + var tables []tableInfo + if err := db.Raw("SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'").Scan(&tables).Error; err != nil { + return fmt.Errorf("读取表排序规则失败 / Failed to read table collations: %v", err) + } + + var badTables []string + for _, t := range tables { + // NULL 或空表示继承库默认设置,已在上面校验库默认,视为通过 + if t.Collation == nil || *t.Collation == "" { + continue + } + cl := *t.Collation + // 仅凭排序规则判断是否中文可用 + ok := false + lower := strings.ToLower(cl) + for _, prefix := range allowedCharsets { + if strings.HasPrefix(lower, prefix) { + ok = true + break + } + } + if !ok { + badTables = append(badTables, fmt.Sprintf("%s(%s)", t.Name, cl)) + } + } + + if len(badTables) > 0 { + // 限制输出数量以避免日志过长 + maxShow := 20 + shown := badTables + if len(shown) > maxShow { + shown = shown[:maxShow] + } + return fmt.Errorf( + "存在不支持中文的表,请修复其排序规则/字符集。示例(最多展示 %d 项):%v / Found tables not Chinese-capable. Please fix their collation/charset. Examples (showing up to %d): %v", + maxShow, shown, maxShow, shown, + ) + } + return nil +} + +var ( + lastPingTime time.Time + pingMutex sync.Mutex +) + +func PingDB() error { + pingMutex.Lock() + defer pingMutex.Unlock() + + if time.Since(lastPingTime) < time.Second*10 { + return nil + } + + sqlDB, err := DB.DB() + if err != nil { + log.Printf("Error getting sql.DB from GORM: %v", err) + return err + } + + err = sqlDB.Ping() + if err != nil { + log.Printf("Error pinging DB: %v", err) + return err + } + + lastPingTime = time.Now() + common.SysLog("Database pinged successfully") + return nil +} diff --git a/model/midjourney.go b/model/midjourney.go new file mode 100644 index 0000000000000000000000000000000000000000..c6ef5de5b7858dee347319f6607bf14f8057953e --- /dev/null +++ b/model/midjourney.go @@ -0,0 +1,207 @@ +package model + +type Midjourney struct { + Id int `json:"id"` + Code int `json:"code"` + UserId int `json:"user_id" gorm:"index"` + Action string `json:"action" gorm:"type:varchar(40);index"` + MjId string `json:"mj_id" gorm:"index"` + Prompt string `json:"prompt"` + PromptEn string `json:"prompt_en"` + Description string `json:"description"` + State string `json:"state"` + SubmitTime int64 `json:"submit_time" gorm:"index"` + StartTime int64 `json:"start_time" gorm:"index"` + FinishTime int64 `json:"finish_time" gorm:"index"` + ImageUrl string `json:"image_url"` + VideoUrl string `json:"video_url"` + VideoUrls string `json:"video_urls"` + Status string `json:"status" gorm:"type:varchar(20);index"` + Progress string `json:"progress" gorm:"type:varchar(30);index"` + FailReason string `json:"fail_reason"` + ChannelId int `json:"channel_id"` + Quota int `json:"quota"` + Buttons string `json:"buttons"` + Properties string `json:"properties"` +} + +// TaskQueryParams 用于包含所有搜索条件的结构体,可以根据需求添加更多字段 +type TaskQueryParams struct { + ChannelID string + MjID string + StartTimestamp string + EndTimestamp string +} + +func GetAllUserTask(userId int, startIdx int, num int, queryParams TaskQueryParams) []*Midjourney { + var tasks []*Midjourney + var err error + + // 初始化查询构建器 + query := DB.Where("user_id = ?", userId) + + if queryParams.MjID != "" { + query = query.Where("mj_id = ?", queryParams.MjID) + } + if queryParams.StartTimestamp != "" { + // 假设您已将前端传来的时间戳转换为数据库所需的时间格式,并处理了时间戳的验证和解析 + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != "" { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + + // 获取数据 + err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&tasks).Error + if err != nil { + return nil + } + + return tasks +} + +func GetAllTasks(startIdx int, num int, queryParams TaskQueryParams) []*Midjourney { + var tasks []*Midjourney + var err error + + // 初始化查询构建器 + query := DB + + // 添加过滤条件 + if queryParams.ChannelID != "" { + query = query.Where("channel_id = ?", queryParams.ChannelID) + } + if queryParams.MjID != "" { + query = query.Where("mj_id = ?", queryParams.MjID) + } + if queryParams.StartTimestamp != "" { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != "" { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + + // 获取数据 + err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&tasks).Error + if err != nil { + return nil + } + + return tasks +} + +func GetAllUnFinishTasks() []*Midjourney { + var tasks []*Midjourney + var err error + // get all tasks progress is not 100% + err = DB.Where("progress != ?", "100%").Find(&tasks).Error + if err != nil { + return nil + } + return tasks +} + +func GetByOnlyMJId(mjId string) *Midjourney { + var mj *Midjourney + var err error + err = DB.Where("mj_id = ?", mjId).First(&mj).Error + if err != nil { + return nil + } + return mj +} + +func GetByMJId(userId int, mjId string) *Midjourney { + var mj *Midjourney + var err error + err = DB.Where("user_id = ? and mj_id = ?", userId, mjId).First(&mj).Error + if err != nil { + return nil + } + return mj +} + +func GetByMJIds(userId int, mjIds []string) []*Midjourney { + var mj []*Midjourney + var err error + err = DB.Where("user_id = ? and mj_id in (?)", userId, mjIds).Find(&mj).Error + if err != nil { + return nil + } + return mj +} + +func GetMjByuId(id int) *Midjourney { + var mj *Midjourney + var err error + err = DB.Where("id = ?", id).First(&mj).Error + if err != nil { + return nil + } + return mj +} + +func UpdateProgress(id int, progress string) error { + return DB.Model(&Midjourney{}).Where("id = ?", id).Update("progress", progress).Error +} + +func (midjourney *Midjourney) Insert() error { + var err error + err = DB.Create(midjourney).Error + return err +} + +func (midjourney *Midjourney) Update() error { + var err error + err = DB.Save(midjourney).Error + return err +} + +func MjBulkUpdate(mjIds []string, params map[string]any) error { + return DB.Model(&Midjourney{}). + Where("mj_id in (?)", mjIds). + Updates(params).Error +} + +func MjBulkUpdateByTaskIds(taskIDs []int, params map[string]any) error { + return DB.Model(&Midjourney{}). + Where("id in (?)", taskIDs). + Updates(params).Error +} + +// CountAllTasks returns total midjourney tasks for admin query +func CountAllTasks(queryParams TaskQueryParams) int64 { + var total int64 + query := DB.Model(&Midjourney{}) + if queryParams.ChannelID != "" { + query = query.Where("channel_id = ?", queryParams.ChannelID) + } + if queryParams.MjID != "" { + query = query.Where("mj_id = ?", queryParams.MjID) + } + if queryParams.StartTimestamp != "" { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != "" { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + _ = query.Count(&total).Error + return total +} + +// CountAllUserTask returns total midjourney tasks for user +func CountAllUserTask(userId int, queryParams TaskQueryParams) int64 { + var total int64 + query := DB.Model(&Midjourney{}).Where("user_id = ?", userId) + if queryParams.MjID != "" { + query = query.Where("mj_id = ?", queryParams.MjID) + } + if queryParams.StartTimestamp != "" { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != "" { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + _ = query.Count(&total).Error + return total +} diff --git a/model/missing_models.go b/model/missing_models.go new file mode 100644 index 0000000000000000000000000000000000000000..18191ba680249aab4d5c22561c7d74897b25fa73 --- /dev/null +++ b/model/missing_models.go @@ -0,0 +1,30 @@ +package model + +// GetMissingModels returns model names that are referenced in the system +func GetMissingModels() ([]string, error) { + // 1. 获取所有已启用模型(去重) + models := GetEnabledModels() + if len(models) == 0 { + return []string{}, nil + } + + // 2. 查询已有的元数据模型名 + var existing []string + if err := DB.Model(&Model{}).Where("model_name IN ?", models).Pluck("model_name", &existing).Error; err != nil { + return nil, err + } + + existingSet := make(map[string]struct{}, len(existing)) + for _, e := range existing { + existingSet[e] = struct{}{} + } + + // 3. 收集缺失模型 + var missing []string + for _, name := range models { + if _, ok := existingSet[name]; !ok { + missing = append(missing, name) + } + } + return missing, nil +} diff --git a/model/model_extra.go b/model/model_extra.go new file mode 100644 index 0000000000000000000000000000000000000000..71fd84e7b1e984dae8451a8f9b24384ddc6527b2 --- /dev/null +++ b/model/model_extra.go @@ -0,0 +1,31 @@ +package model + +func GetModelEnableGroups(modelName string) []string { + // 确保缓存最新 + GetPricing() + + if modelName == "" { + return make([]string, 0) + } + + modelEnableGroupsLock.RLock() + groups, ok := modelEnableGroups[modelName] + modelEnableGroupsLock.RUnlock() + if !ok { + return make([]string, 0) + } + return groups +} + +// GetModelQuotaTypes 返回指定模型的计费类型集合(来自缓存) +func GetModelQuotaTypes(modelName string) []int { + GetPricing() + + modelEnableGroupsLock.RLock() + quota, ok := modelQuotaTypeMap[modelName] + modelEnableGroupsLock.RUnlock() + if !ok { + return []int{} + } + return []int{quota} +} diff --git a/model/model_meta.go b/model/model_meta.go new file mode 100644 index 0000000000000000000000000000000000000000..465d15f1df7b06a92c4ac68f6b9c2119b016e8fd --- /dev/null +++ b/model/model_meta.go @@ -0,0 +1,148 @@ +package model + +import ( + "strconv" + + "github.com/QuantumNous/new-api/common" + + "gorm.io/gorm" +) + +const ( + NameRuleExact = iota + NameRulePrefix + NameRuleContains + NameRuleSuffix +) + +type BoundChannel struct { + Name string `json:"name"` + Type int `json:"type"` +} + +type Model struct { + Id int `json:"id"` + ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"` + Description string `json:"description,omitempty" gorm:"type:text"` + Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` + Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` + VendorID int `json:"vendor_id,omitempty" gorm:"index"` + Endpoints string `json:"endpoints,omitempty" gorm:"type:text"` + Status int `json:"status" gorm:"default:1"` + SyncOfficial int `json:"sync_official" gorm:"default:1"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"` + + BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"` + EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"` + QuotaTypes []int `json:"quota_types,omitempty" gorm:"-"` + NameRule int `json:"name_rule" gorm:"default:0"` + + MatchedModels []string `json:"matched_models,omitempty" gorm:"-"` + MatchedCount int `json:"matched_count,omitempty" gorm:"-"` +} + +func (mi *Model) Insert() error { + now := common.GetTimestamp() + mi.CreatedTime = now + mi.UpdatedTime = now + return DB.Create(mi).Error +} + +func IsModelNameDuplicated(id int, name string) (bool, error) { + if name == "" { + return false, nil + } + var cnt int64 + err := DB.Model(&Model{}).Where("model_name = ? AND id <> ?", name, id).Count(&cnt).Error + return cnt > 0, err +} + +func (mi *Model) Update() error { + mi.UpdatedTime = common.GetTimestamp() + return DB.Session(&gorm.Session{AllowGlobalUpdate: false, FullSaveAssociations: false}). + Model(&Model{}). + Where("id = ?", mi.Id). + Omit("created_time"). + Select("*"). + Updates(mi).Error +} + +func (mi *Model) Delete() error { + return DB.Delete(mi).Error +} + +func GetVendorModelCounts() (map[int64]int64, error) { + var stats []struct { + VendorID int64 + Count int64 + } + if err := DB.Model(&Model{}). + Select("vendor_id as vendor_id, count(*) as count"). + Group("vendor_id"). + Scan(&stats).Error; err != nil { + return nil, err + } + m := make(map[int64]int64, len(stats)) + for _, s := range stats { + m[s.VendorID] = s.Count + } + return m, nil +} + +func GetAllModels(offset int, limit int) ([]*Model, error) { + var models []*Model + err := DB.Order("id DESC").Offset(offset).Limit(limit).Find(&models).Error + return models, err +} + +func GetBoundChannelsByModelsMap(modelNames []string) (map[string][]BoundChannel, error) { + result := make(map[string][]BoundChannel) + if len(modelNames) == 0 { + return result, nil + } + type row struct { + Model string + Name string + Type int + } + var rows []row + err := DB.Table("channels"). + Select("abilities.model as model, channels.name as name, channels.type as type"). + Joins("JOIN abilities ON abilities.channel_id = channels.id"). + Where("abilities.model IN ? AND abilities.enabled = ?", modelNames, true). + Distinct(). + Scan(&rows).Error + if err != nil { + return nil, err + } + for _, r := range rows { + result[r.Model] = append(result[r.Model], BoundChannel{Name: r.Name, Type: r.Type}) + } + return result, nil +} + +func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Model, int64, error) { + var models []*Model + db := DB.Model(&Model{}) + if keyword != "" { + like := "%" + keyword + "%" + db = db.Where("model_name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like) + } + if vendor != "" { + if vid, err := strconv.Atoi(vendor); err == nil { + db = db.Where("models.vendor_id = ?", vid) + } else { + db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%") + } + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := db.Order("models.id DESC").Offset(offset).Limit(limit).Find(&models).Error; err != nil { + return nil, 0, err + } + return models, total, nil +} diff --git a/model/option.go b/model/option.go new file mode 100644 index 0000000000000000000000000000000000000000..e9fd50d7f35787b0ade93d41499a98c59cdb38b9 --- /dev/null +++ b/model/option.go @@ -0,0 +1,478 @@ +package model + +import ( + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/config" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/setting/system_setting" +) + +type Option struct { + Key string `json:"key" gorm:"primaryKey"` + Value string `json:"value"` +} + +func AllOption() ([]*Option, error) { + var options []*Option + var err error + err = DB.Find(&options).Error + return options, err +} + +func InitOptionMap() { + common.OptionMapRWMutex.Lock() + common.OptionMap = make(map[string]string) + + // 添加原有的系统配置 + common.OptionMap["FileUploadPermission"] = strconv.Itoa(common.FileUploadPermission) + common.OptionMap["FileDownloadPermission"] = strconv.Itoa(common.FileDownloadPermission) + common.OptionMap["ImageUploadPermission"] = strconv.Itoa(common.ImageUploadPermission) + common.OptionMap["ImageDownloadPermission"] = strconv.Itoa(common.ImageDownloadPermission) + common.OptionMap["PasswordLoginEnabled"] = strconv.FormatBool(common.PasswordLoginEnabled) + common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled) + common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled) + common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled) + common.OptionMap["LinuxDOOAuthEnabled"] = strconv.FormatBool(common.LinuxDOOAuthEnabled) + common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled) + common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled) + common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled) + common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled) + common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled) + common.OptionMap["AutomaticEnableChannelEnabled"] = strconv.FormatBool(common.AutomaticEnableChannelEnabled) + common.OptionMap["LogConsumeEnabled"] = strconv.FormatBool(common.LogConsumeEnabled) + common.OptionMap["DisplayInCurrencyEnabled"] = strconv.FormatBool(common.DisplayInCurrencyEnabled) + common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled) + common.OptionMap["DrawingEnabled"] = strconv.FormatBool(common.DrawingEnabled) + common.OptionMap["TaskEnabled"] = strconv.FormatBool(common.TaskEnabled) + common.OptionMap["DataExportEnabled"] = strconv.FormatBool(common.DataExportEnabled) + common.OptionMap["ChannelDisableThreshold"] = strconv.FormatFloat(common.ChannelDisableThreshold, 'f', -1, 64) + common.OptionMap["EmailDomainRestrictionEnabled"] = strconv.FormatBool(common.EmailDomainRestrictionEnabled) + common.OptionMap["EmailAliasRestrictionEnabled"] = strconv.FormatBool(common.EmailAliasRestrictionEnabled) + common.OptionMap["EmailDomainWhitelist"] = strings.Join(common.EmailDomainWhitelist, ",") + common.OptionMap["SMTPServer"] = "" + common.OptionMap["SMTPFrom"] = "" + common.OptionMap["SMTPPort"] = strconv.Itoa(common.SMTPPort) + common.OptionMap["SMTPAccount"] = "" + common.OptionMap["SMTPToken"] = "" + common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled) + common.OptionMap["Notice"] = "" + common.OptionMap["About"] = "" + common.OptionMap["HomePageContent"] = "" + common.OptionMap["Footer"] = common.Footer + common.OptionMap["SystemName"] = common.SystemName + common.OptionMap["Logo"] = common.Logo + common.OptionMap["ServerAddress"] = "" + common.OptionMap["WorkerUrl"] = system_setting.WorkerUrl + common.OptionMap["WorkerValidKey"] = system_setting.WorkerValidKey + common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(system_setting.WorkerAllowHttpImageRequestEnabled) + common.OptionMap["PayAddress"] = "" + common.OptionMap["CustomCallbackAddress"] = "" + common.OptionMap["EpayId"] = "" + common.OptionMap["EpayKey"] = "" + common.OptionMap["Price"] = strconv.FormatFloat(operation_setting.Price, 'f', -1, 64) + common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(operation_setting.USDExchangeRate, 'f', -1, 64) + common.OptionMap["MinTopUp"] = strconv.Itoa(operation_setting.MinTopUp) + common.OptionMap["StripeMinTopUp"] = strconv.Itoa(setting.StripeMinTopUp) + common.OptionMap["StripeApiSecret"] = setting.StripeApiSecret + common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret + common.OptionMap["StripePriceId"] = setting.StripePriceId + common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64) + common.OptionMap["StripePromotionCodesEnabled"] = strconv.FormatBool(setting.StripePromotionCodesEnabled) + common.OptionMap["CreemApiKey"] = setting.CreemApiKey + common.OptionMap["CreemProducts"] = setting.CreemProducts + common.OptionMap["CreemTestMode"] = strconv.FormatBool(setting.CreemTestMode) + common.OptionMap["CreemWebhookSecret"] = setting.CreemWebhookSecret + common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() + common.OptionMap["Chats"] = setting.Chats2JsonString() + common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() + common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup) + common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString() + common.OptionMap["GitHubClientId"] = "" + common.OptionMap["GitHubClientSecret"] = "" + common.OptionMap["TelegramBotToken"] = "" + common.OptionMap["TelegramBotName"] = "" + common.OptionMap["WeChatServerAddress"] = "" + common.OptionMap["WeChatServerToken"] = "" + common.OptionMap["WeChatAccountQRCodeImageURL"] = "" + common.OptionMap["TurnstileSiteKey"] = "" + common.OptionMap["TurnstileSecretKey"] = "" + common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser) + common.OptionMap["QuotaForInviter"] = strconv.Itoa(common.QuotaForInviter) + common.OptionMap["QuotaForInvitee"] = strconv.Itoa(common.QuotaForInvitee) + common.OptionMap["QuotaRemindThreshold"] = strconv.Itoa(common.QuotaRemindThreshold) + common.OptionMap["PreConsumedQuota"] = strconv.Itoa(common.PreConsumedQuota) + common.OptionMap["ModelRequestRateLimitCount"] = strconv.Itoa(setting.ModelRequestRateLimitCount) + common.OptionMap["ModelRequestRateLimitDurationMinutes"] = strconv.Itoa(setting.ModelRequestRateLimitDurationMinutes) + common.OptionMap["ModelRequestRateLimitSuccessCount"] = strconv.Itoa(setting.ModelRequestRateLimitSuccessCount) + common.OptionMap["ModelRequestRateLimitGroup"] = setting.ModelRequestRateLimitGroup2JSONString() + common.OptionMap["ModelRatio"] = ratio_setting.ModelRatio2JSONString() + common.OptionMap["ModelPrice"] = ratio_setting.ModelPrice2JSONString() + common.OptionMap["CacheRatio"] = ratio_setting.CacheRatio2JSONString() + common.OptionMap["GroupRatio"] = ratio_setting.GroupRatio2JSONString() + common.OptionMap["GroupGroupRatio"] = ratio_setting.GroupGroupRatio2JSONString() + common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString() + common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString() + common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString() + common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString() + common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString() + common.OptionMap["TopUpLink"] = common.TopUpLink + //common.OptionMap["ChatLink"] = common.ChatLink + //common.OptionMap["ChatLink2"] = common.ChatLink2 + common.OptionMap["QuotaPerUnit"] = strconv.FormatFloat(common.QuotaPerUnit, 'f', -1, 64) + common.OptionMap["RetryTimes"] = strconv.Itoa(common.RetryTimes) + common.OptionMap["DataExportInterval"] = strconv.Itoa(common.DataExportInterval) + common.OptionMap["DataExportDefaultTime"] = common.DataExportDefaultTime + common.OptionMap["DefaultCollapseSidebar"] = strconv.FormatBool(common.DefaultCollapseSidebar) + common.OptionMap["MjNotifyEnabled"] = strconv.FormatBool(setting.MjNotifyEnabled) + common.OptionMap["MjAccountFilterEnabled"] = strconv.FormatBool(setting.MjAccountFilterEnabled) + common.OptionMap["MjModeClearEnabled"] = strconv.FormatBool(setting.MjModeClearEnabled) + common.OptionMap["MjForwardUrlEnabled"] = strconv.FormatBool(setting.MjForwardUrlEnabled) + common.OptionMap["MjActionCheckSuccessEnabled"] = strconv.FormatBool(setting.MjActionCheckSuccessEnabled) + common.OptionMap["CheckSensitiveEnabled"] = strconv.FormatBool(setting.CheckSensitiveEnabled) + common.OptionMap["DemoSiteEnabled"] = strconv.FormatBool(operation_setting.DemoSiteEnabled) + common.OptionMap["SelfUseModeEnabled"] = strconv.FormatBool(operation_setting.SelfUseModeEnabled) + common.OptionMap["ModelRequestRateLimitEnabled"] = strconv.FormatBool(setting.ModelRequestRateLimitEnabled) + common.OptionMap["CheckSensitiveOnPromptEnabled"] = strconv.FormatBool(setting.CheckSensitiveOnPromptEnabled) + common.OptionMap["StopOnSensitiveEnabled"] = strconv.FormatBool(setting.StopOnSensitiveEnabled) + common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() + common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) + common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() + common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled()) + + // 自动添加所有注册的模型配置 + modelConfigs := config.GlobalConfig.ExportAllConfigs() + for k, v := range modelConfigs { + common.OptionMap[k] = v + } + + common.OptionMapRWMutex.Unlock() + loadOptionsFromDatabase() +} + +func loadOptionsFromDatabase() { + options, _ := AllOption() + for _, option := range options { + err := updateOptionMap(option.Key, option.Value) + if err != nil { + common.SysLog("failed to update option map: " + err.Error()) + } + } +} + +func SyncOptions(frequency int) { + for { + time.Sleep(time.Duration(frequency) * time.Second) + common.SysLog("syncing options from database") + loadOptionsFromDatabase() + } +} + +func UpdateOption(key string, value string) error { + // Save to database first + option := Option{ + Key: key, + } + // https://gorm.io/docs/update.html#Save-All-Fields + DB.FirstOrCreate(&option, Option{Key: key}) + option.Value = value + // Save is a combination function. + // If save value does not contain primary key, it will execute Create, + // otherwise it will execute Update (with all fields). + DB.Save(&option) + // Update OptionMap + return updateOptionMap(key, value) +} + +func updateOptionMap(key string, value string) (err error) { + common.OptionMapRWMutex.Lock() + defer common.OptionMapRWMutex.Unlock() + common.OptionMap[key] = value + + // 检查是否是模型配置 - 使用更规范的方式处理 + if handleConfigUpdate(key, value) { + return nil // 已由配置系统处理 + } + + // 处理传统配置项... + if strings.HasSuffix(key, "Permission") { + intValue, _ := strconv.Atoi(value) + switch key { + case "FileUploadPermission": + common.FileUploadPermission = intValue + case "FileDownloadPermission": + common.FileDownloadPermission = intValue + case "ImageUploadPermission": + common.ImageUploadPermission = intValue + case "ImageDownloadPermission": + common.ImageDownloadPermission = intValue + } + } + if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" { + boolValue := value == "true" + switch key { + case "PasswordRegisterEnabled": + common.PasswordRegisterEnabled = boolValue + case "PasswordLoginEnabled": + common.PasswordLoginEnabled = boolValue + case "EmailVerificationEnabled": + common.EmailVerificationEnabled = boolValue + case "GitHubOAuthEnabled": + common.GitHubOAuthEnabled = boolValue + case "LinuxDOOAuthEnabled": + common.LinuxDOOAuthEnabled = boolValue + case "WeChatAuthEnabled": + common.WeChatAuthEnabled = boolValue + case "TelegramOAuthEnabled": + common.TelegramOAuthEnabled = boolValue + case "TurnstileCheckEnabled": + common.TurnstileCheckEnabled = boolValue + case "RegisterEnabled": + common.RegisterEnabled = boolValue + case "EmailDomainRestrictionEnabled": + common.EmailDomainRestrictionEnabled = boolValue + case "EmailAliasRestrictionEnabled": + common.EmailAliasRestrictionEnabled = boolValue + case "AutomaticDisableChannelEnabled": + common.AutomaticDisableChannelEnabled = boolValue + case "AutomaticEnableChannelEnabled": + common.AutomaticEnableChannelEnabled = boolValue + case "LogConsumeEnabled": + common.LogConsumeEnabled = boolValue + case "DisplayInCurrencyEnabled": + // 兼容旧字段:同步到新配置 general_setting.quota_display_type(运行时生效) + // true -> USD, false -> TOKENS + newVal := "USD" + if !boolValue { + newVal = "TOKENS" + } + if cfg := config.GlobalConfig.Get("general_setting"); cfg != nil { + _ = config.UpdateConfigFromMap(cfg, map[string]string{"quota_display_type": newVal}) + } + case "DisplayTokenStatEnabled": + common.DisplayTokenStatEnabled = boolValue + case "DrawingEnabled": + common.DrawingEnabled = boolValue + case "TaskEnabled": + common.TaskEnabled = boolValue + case "DataExportEnabled": + common.DataExportEnabled = boolValue + case "DefaultCollapseSidebar": + common.DefaultCollapseSidebar = boolValue + case "MjNotifyEnabled": + setting.MjNotifyEnabled = boolValue + case "MjAccountFilterEnabled": + setting.MjAccountFilterEnabled = boolValue + case "MjModeClearEnabled": + setting.MjModeClearEnabled = boolValue + case "MjForwardUrlEnabled": + setting.MjForwardUrlEnabled = boolValue + case "MjActionCheckSuccessEnabled": + setting.MjActionCheckSuccessEnabled = boolValue + case "CheckSensitiveEnabled": + setting.CheckSensitiveEnabled = boolValue + case "DemoSiteEnabled": + operation_setting.DemoSiteEnabled = boolValue + case "SelfUseModeEnabled": + operation_setting.SelfUseModeEnabled = boolValue + case "CheckSensitiveOnPromptEnabled": + setting.CheckSensitiveOnPromptEnabled = boolValue + case "ModelRequestRateLimitEnabled": + setting.ModelRequestRateLimitEnabled = boolValue + case "StopOnSensitiveEnabled": + setting.StopOnSensitiveEnabled = boolValue + case "SMTPSSLEnabled": + common.SMTPSSLEnabled = boolValue + case "WorkerAllowHttpImageRequestEnabled": + system_setting.WorkerAllowHttpImageRequestEnabled = boolValue + case "DefaultUseAutoGroup": + setting.DefaultUseAutoGroup = boolValue + case "ExposeRatioEnabled": + ratio_setting.SetExposeRatioEnabled(boolValue) + } + } + switch key { + case "EmailDomainWhitelist": + common.EmailDomainWhitelist = strings.Split(value, ",") + case "SMTPServer": + common.SMTPServer = value + case "SMTPPort": + intValue, _ := strconv.Atoi(value) + common.SMTPPort = intValue + case "SMTPAccount": + common.SMTPAccount = value + case "SMTPFrom": + common.SMTPFrom = value + case "SMTPToken": + common.SMTPToken = value + case "ServerAddress": + system_setting.ServerAddress = value + case "WorkerUrl": + system_setting.WorkerUrl = value + case "WorkerValidKey": + system_setting.WorkerValidKey = value + case "PayAddress": + operation_setting.PayAddress = value + case "Chats": + err = setting.UpdateChatsByJsonString(value) + case "AutoGroups": + err = setting.UpdateAutoGroupsByJsonString(value) + case "CustomCallbackAddress": + operation_setting.CustomCallbackAddress = value + case "EpayId": + operation_setting.EpayId = value + case "EpayKey": + operation_setting.EpayKey = value + case "Price": + operation_setting.Price, _ = strconv.ParseFloat(value, 64) + case "USDExchangeRate": + operation_setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64) + case "MinTopUp": + operation_setting.MinTopUp, _ = strconv.Atoi(value) + case "StripeApiSecret": + setting.StripeApiSecret = value + case "StripeWebhookSecret": + setting.StripeWebhookSecret = value + case "StripePriceId": + setting.StripePriceId = value + case "StripeUnitPrice": + setting.StripeUnitPrice, _ = strconv.ParseFloat(value, 64) + case "StripeMinTopUp": + setting.StripeMinTopUp, _ = strconv.Atoi(value) + case "StripePromotionCodesEnabled": + setting.StripePromotionCodesEnabled = value == "true" + case "CreemApiKey": + setting.CreemApiKey = value + case "CreemProducts": + setting.CreemProducts = value + case "CreemTestMode": + setting.CreemTestMode = value == "true" + case "CreemWebhookSecret": + setting.CreemWebhookSecret = value + case "TopupGroupRatio": + err = common.UpdateTopupGroupRatioByJSONString(value) + case "GitHubClientId": + common.GitHubClientId = value + case "GitHubClientSecret": + common.GitHubClientSecret = value + case "LinuxDOClientId": + common.LinuxDOClientId = value + case "LinuxDOClientSecret": + common.LinuxDOClientSecret = value + case "LinuxDOMinimumTrustLevel": + common.LinuxDOMinimumTrustLevel, _ = strconv.Atoi(value) + case "Footer": + common.Footer = value + case "SystemName": + common.SystemName = value + case "Logo": + common.Logo = value + case "WeChatServerAddress": + common.WeChatServerAddress = value + case "WeChatServerToken": + common.WeChatServerToken = value + case "WeChatAccountQRCodeImageURL": + common.WeChatAccountQRCodeImageURL = value + case "TelegramBotToken": + common.TelegramBotToken = value + case "TelegramBotName": + common.TelegramBotName = value + case "TurnstileSiteKey": + common.TurnstileSiteKey = value + case "TurnstileSecretKey": + common.TurnstileSecretKey = value + case "QuotaForNewUser": + common.QuotaForNewUser, _ = strconv.Atoi(value) + case "QuotaForInviter": + common.QuotaForInviter, _ = strconv.Atoi(value) + case "QuotaForInvitee": + common.QuotaForInvitee, _ = strconv.Atoi(value) + case "QuotaRemindThreshold": + common.QuotaRemindThreshold, _ = strconv.Atoi(value) + case "PreConsumedQuota": + common.PreConsumedQuota, _ = strconv.Atoi(value) + case "ModelRequestRateLimitCount": + setting.ModelRequestRateLimitCount, _ = strconv.Atoi(value) + case "ModelRequestRateLimitDurationMinutes": + setting.ModelRequestRateLimitDurationMinutes, _ = strconv.Atoi(value) + case "ModelRequestRateLimitSuccessCount": + setting.ModelRequestRateLimitSuccessCount, _ = strconv.Atoi(value) + case "ModelRequestRateLimitGroup": + err = setting.UpdateModelRequestRateLimitGroupByJSONString(value) + case "RetryTimes": + common.RetryTimes, _ = strconv.Atoi(value) + case "DataExportInterval": + common.DataExportInterval, _ = strconv.Atoi(value) + case "DataExportDefaultTime": + common.DataExportDefaultTime = value + case "ModelRatio": + err = ratio_setting.UpdateModelRatioByJSONString(value) + case "GroupRatio": + err = ratio_setting.UpdateGroupRatioByJSONString(value) + case "GroupGroupRatio": + err = ratio_setting.UpdateGroupGroupRatioByJSONString(value) + case "UserUsableGroups": + err = setting.UpdateUserUsableGroupsByJSONString(value) + case "CompletionRatio": + err = ratio_setting.UpdateCompletionRatioByJSONString(value) + case "ModelPrice": + err = ratio_setting.UpdateModelPriceByJSONString(value) + case "CacheRatio": + err = ratio_setting.UpdateCacheRatioByJSONString(value) + case "ImageRatio": + err = ratio_setting.UpdateImageRatioByJSONString(value) + case "AudioRatio": + err = ratio_setting.UpdateAudioRatioByJSONString(value) + case "AudioCompletionRatio": + err = ratio_setting.UpdateAudioCompletionRatioByJSONString(value) + case "TopUpLink": + common.TopUpLink = value + //case "ChatLink": + // common.ChatLink = value + //case "ChatLink2": + // common.ChatLink2 = value + case "ChannelDisableThreshold": + common.ChannelDisableThreshold, _ = strconv.ParseFloat(value, 64) + case "QuotaPerUnit": + common.QuotaPerUnit, _ = strconv.ParseFloat(value, 64) + case "SensitiveWords": + setting.SensitiveWordsFromString(value) + case "AutomaticDisableKeywords": + operation_setting.AutomaticDisableKeywordsFromString(value) + case "StreamCacheQueueLength": + setting.StreamCacheQueueLength, _ = strconv.Atoi(value) + case "PayMethods": + err = operation_setting.UpdatePayMethodsByJsonString(value) + } + return err +} + +// handleConfigUpdate 处理分层配置更新,返回是否已处理 +func handleConfigUpdate(key, value string) bool { + parts := strings.SplitN(key, ".", 2) + if len(parts) != 2 { + return false // 不是分层配置 + } + + configName := parts[0] + configKey := parts[1] + + // 获取配置对象 + cfg := config.GlobalConfig.Get(configName) + if cfg == nil { + return false // 未注册的配置 + } + + // 更新配置 + configMap := map[string]string{ + configKey: value, + } + config.UpdateConfigFromMap(cfg, configMap) + + return true // 已处理 +} diff --git a/model/passkey.go b/model/passkey.go new file mode 100644 index 0000000000000000000000000000000000000000..5d2595cf8aaa586c22197162ab3ca7244a050c32 --- /dev/null +++ b/model/passkey.go @@ -0,0 +1,210 @@ +package model + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "gorm.io/gorm" +) + +var ( + ErrPasskeyNotFound = errors.New("passkey credential not found") + ErrFriendlyPasskeyNotFound = errors.New("Passkey 验证失败,请重试或联系管理员") +) + +type PasskeyCredential struct { + ID int `json:"id" gorm:"primaryKey"` + UserID int `json:"user_id" gorm:"uniqueIndex;not null"` + CredentialID string `json:"credential_id" gorm:"type:varchar(512);uniqueIndex;not null"` // base64 encoded + PublicKey string `json:"public_key" gorm:"type:text;not null"` // base64 encoded + AttestationType string `json:"attestation_type" gorm:"type:varchar(255)"` + AAGUID string `json:"aaguid" gorm:"type:varchar(512)"` // base64 encoded + SignCount uint32 `json:"sign_count" gorm:"default:0"` + CloneWarning bool `json:"clone_warning"` + UserPresent bool `json:"user_present"` + UserVerified bool `json:"user_verified"` + BackupEligible bool `json:"backup_eligible"` + BackupState bool `json:"backup_state"` + Transports string `json:"transports" gorm:"type:text"` + Attachment string `json:"attachment" gorm:"type:varchar(32)"` + LastUsedAt *time.Time `json:"last_used_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +func (p *PasskeyCredential) TransportList() []protocol.AuthenticatorTransport { + if p == nil || strings.TrimSpace(p.Transports) == "" { + return nil + } + var transports []string + if err := json.Unmarshal([]byte(p.Transports), &transports); err != nil { + return nil + } + result := make([]protocol.AuthenticatorTransport, 0, len(transports)) + for _, transport := range transports { + result = append(result, protocol.AuthenticatorTransport(transport)) + } + return result +} + +func (p *PasskeyCredential) SetTransports(list []protocol.AuthenticatorTransport) { + if len(list) == 0 { + p.Transports = "" + return + } + stringList := make([]string, len(list)) + for i, transport := range list { + stringList[i] = string(transport) + } + encoded, err := json.Marshal(stringList) + if err != nil { + return + } + p.Transports = string(encoded) +} + +func (p *PasskeyCredential) ToWebAuthnCredential() webauthn.Credential { + flags := webauthn.CredentialFlags{ + UserPresent: p.UserPresent, + UserVerified: p.UserVerified, + BackupEligible: p.BackupEligible, + BackupState: p.BackupState, + } + + credID, _ := base64.StdEncoding.DecodeString(p.CredentialID) + pubKey, _ := base64.StdEncoding.DecodeString(p.PublicKey) + aaguid, _ := base64.StdEncoding.DecodeString(p.AAGUID) + + return webauthn.Credential{ + ID: credID, + PublicKey: pubKey, + AttestationType: p.AttestationType, + Transport: p.TransportList(), + Flags: flags, + Authenticator: webauthn.Authenticator{ + AAGUID: aaguid, + SignCount: p.SignCount, + CloneWarning: p.CloneWarning, + Attachment: protocol.AuthenticatorAttachment(p.Attachment), + }, + } +} + +func NewPasskeyCredentialFromWebAuthn(userID int, credential *webauthn.Credential) *PasskeyCredential { + if credential == nil { + return nil + } + passkey := &PasskeyCredential{ + UserID: userID, + CredentialID: base64.StdEncoding.EncodeToString(credential.ID), + PublicKey: base64.StdEncoding.EncodeToString(credential.PublicKey), + AttestationType: credential.AttestationType, + AAGUID: base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID), + SignCount: credential.Authenticator.SignCount, + CloneWarning: credential.Authenticator.CloneWarning, + UserPresent: credential.Flags.UserPresent, + UserVerified: credential.Flags.UserVerified, + BackupEligible: credential.Flags.BackupEligible, + BackupState: credential.Flags.BackupState, + Attachment: string(credential.Authenticator.Attachment), + } + passkey.SetTransports(credential.Transport) + return passkey +} + +func (p *PasskeyCredential) ApplyValidatedCredential(credential *webauthn.Credential) { + if credential == nil || p == nil { + return + } + p.CredentialID = base64.StdEncoding.EncodeToString(credential.ID) + p.PublicKey = base64.StdEncoding.EncodeToString(credential.PublicKey) + p.AttestationType = credential.AttestationType + p.AAGUID = base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID) + p.SignCount = credential.Authenticator.SignCount + p.CloneWarning = credential.Authenticator.CloneWarning + p.UserPresent = credential.Flags.UserPresent + p.UserVerified = credential.Flags.UserVerified + p.BackupEligible = credential.Flags.BackupEligible + p.BackupState = credential.Flags.BackupState + p.Attachment = string(credential.Authenticator.Attachment) + p.SetTransports(credential.Transport) +} + +func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) { + if userID == 0 { + common.SysLog("GetPasskeyByUserID: empty user ID") + return nil, ErrFriendlyPasskeyNotFound + } + var credential PasskeyCredential + if err := DB.Where("user_id = ?", userID).First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // 未找到记录是正常情况(用户未绑定),返回 ErrPasskeyNotFound 而不记录日志 + return nil, ErrPasskeyNotFound + } + // 只有真正的数据库错误才记录日志 + common.SysLog(fmt.Sprintf("GetPasskeyByUserID: database error for user %d: %v", userID, err)) + return nil, ErrFriendlyPasskeyNotFound + } + return &credential, nil +} + +func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) { + if len(credentialID) == 0 { + common.SysLog("GetPasskeyByCredentialID: empty credential ID") + return nil, ErrFriendlyPasskeyNotFound + } + + credIDStr := base64.StdEncoding.EncodeToString(credentialID) + var credential PasskeyCredential + if err := DB.Where("credential_id = ?", credIDStr).First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: passkey not found for credential ID length %d", len(credentialID))) + return nil, ErrFriendlyPasskeyNotFound + } + common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: database error for credential ID: %v", err)) + return nil, ErrFriendlyPasskeyNotFound + } + + return &credential, nil +} + +func UpsertPasskeyCredential(credential *PasskeyCredential) error { + if credential == nil { + common.SysLog("UpsertPasskeyCredential: nil credential provided") + return fmt.Errorf("Passkey 保存失败,请重试") + } + return DB.Transaction(func(tx *gorm.DB) error { + // 使用Unscoped()进行硬删除,避免唯一索引冲突 + if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil { + common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err)) + return fmt.Errorf("Passkey 保存失败,请重试") + } + if err := tx.Create(credential).Error; err != nil { + common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err)) + return fmt.Errorf("Passkey 保存失败,请重试") + } + return nil + }) +} + +func DeletePasskeyByUserID(userID int) error { + if userID == 0 { + common.SysLog("DeletePasskeyByUserID: empty user ID") + return fmt.Errorf("删除失败,请重试") + } + // 使用Unscoped()进行硬删除,避免唯一索引冲突 + if err := DB.Unscoped().Where("user_id = ?", userID).Delete(&PasskeyCredential{}).Error; err != nil { + common.SysLog(fmt.Sprintf("DeletePasskeyByUserID: failed to delete passkey for user %d: %v", userID, err)) + return fmt.Errorf("删除失败,请重试") + } + return nil +} diff --git a/model/prefill_group.go b/model/prefill_group.go new file mode 100644 index 0000000000000000000000000000000000000000..cc2e64da992eb7a9ff6f9342b94964ccea27294e --- /dev/null +++ b/model/prefill_group.go @@ -0,0 +1,127 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + + "github.com/QuantumNous/new-api/common" + + "gorm.io/gorm" +) + +// PrefillGroup 用于存储可复用的“组”信息,例如模型组、标签组、端点组等。 +// Name 字段保持唯一,用于在前端下拉框中展示。 +// Type 字段用于区分组的类别,可选值如:model、tag、endpoint。 +// Items 字段使用 JSON 数组保存对应类型的字符串集合,示例: +// ["gpt-4o", "gpt-3.5-turbo"] +// 设计遵循 3NF,避免冗余,提供灵活扩展能力。 + +// JSONValue 基于 json.RawMessage 实现,支持从数据库的 []byte 和 string 两种类型读取 +type JSONValue json.RawMessage + +// Value 实现 driver.Valuer 接口,用于数据库写入 +func (j JSONValue) Value() (driver.Value, error) { + if j == nil { + return nil, nil + } + return []byte(j), nil +} + +// Scan 实现 sql.Scanner 接口,兼容不同驱动返回的类型 +func (j *JSONValue) Scan(value interface{}) error { + switch v := value.(type) { + case nil: + *j = nil + return nil + case []byte: + // 拷贝底层字节,避免保留底层缓冲区 + b := make([]byte, len(v)) + copy(b, v) + *j = JSONValue(b) + return nil + case string: + *j = JSONValue([]byte(v)) + return nil + default: + // 其他类型尝试序列化为 JSON + b, err := json.Marshal(v) + if err != nil { + return err + } + *j = JSONValue(b) + return nil + } +} + +// MarshalJSON 确保在对外编码时与 json.RawMessage 行为一致 +func (j JSONValue) MarshalJSON() ([]byte, error) { + if j == nil { + return []byte("null"), nil + } + return j, nil +} + +// UnmarshalJSON 确保在对外解码时与 json.RawMessage 行为一致 +func (j *JSONValue) UnmarshalJSON(data []byte) error { + if data == nil { + *j = nil + return nil + } + b := make([]byte, len(data)) + copy(b, data) + *j = JSONValue(b) + return nil +} + +type PrefillGroup struct { + Id int `json:"id"` + Name string `json:"name" gorm:"size:64;not null;uniqueIndex:uk_prefill_name,where:deleted_at IS NULL"` + Type string `json:"type" gorm:"size:32;index;not null"` + Items JSONValue `json:"items" gorm:"type:json"` + Description string `json:"description,omitempty" gorm:"type:varchar(255)"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// Insert 新建组 +func (g *PrefillGroup) Insert() error { + now := common.GetTimestamp() + g.CreatedTime = now + g.UpdatedTime = now + return DB.Create(g).Error +} + +// IsPrefillGroupNameDuplicated 检查组名称是否重复(排除自身 ID) +func IsPrefillGroupNameDuplicated(id int, name string) (bool, error) { + if name == "" { + return false, nil + } + var cnt int64 + err := DB.Model(&PrefillGroup{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error + return cnt > 0, err +} + +// Update 更新组 +func (g *PrefillGroup) Update() error { + g.UpdatedTime = common.GetTimestamp() + return DB.Save(g).Error +} + +// DeleteByID 根据 ID 删除组 +func DeletePrefillGroupByID(id int) error { + return DB.Delete(&PrefillGroup{}, id).Error +} + +// GetAllPrefillGroups 获取全部组,可按类型过滤(为空则返回全部) +func GetAllPrefillGroups(groupType string) ([]*PrefillGroup, error) { + var groups []*PrefillGroup + query := DB.Model(&PrefillGroup{}) + if groupType != "" { + query = query.Where("type = ?", groupType) + } + if err := query.Order("updated_time DESC").Find(&groups).Error; err != nil { + return nil, err + } + return groups, nil +} diff --git a/model/pricing.go b/model/pricing.go new file mode 100644 index 0000000000000000000000000000000000000000..d56068a10db781f62e2316d09a008518e03a34d6 --- /dev/null +++ b/model/pricing.go @@ -0,0 +1,313 @@ +package model + +import ( + "encoding/json" + "fmt" + "strings" + + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" +) + +type Pricing struct { + ModelName string `json:"model_name"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + Tags string `json:"tags,omitempty"` + VendorID int `json:"vendor_id,omitempty"` + QuotaType int `json:"quota_type"` + ModelRatio float64 `json:"model_ratio"` + ModelPrice float64 `json:"model_price"` + OwnerBy string `json:"owner_by"` + CompletionRatio float64 `json:"completion_ratio"` + EnableGroup []string `json:"enable_groups"` + SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` +} + +type PricingVendor struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` +} + +var ( + pricingMap []Pricing + vendorsList []PricingVendor + supportedEndpointMap map[string]common.EndpointInfo + lastGetPricingTime time.Time + updatePricingLock sync.Mutex + + // 缓存映射:模型名 -> 启用分组 / 计费类型 + modelEnableGroups = make(map[string][]string) + modelQuotaTypeMap = make(map[string]int) + modelEnableGroupsLock = sync.RWMutex{} +) + +var ( + modelSupportEndpointTypes = make(map[string][]constant.EndpointType) + modelSupportEndpointsLock = sync.RWMutex{} +) + +func GetPricing() []Pricing { + if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 { + updatePricingLock.Lock() + defer updatePricingLock.Unlock() + // Double check after acquiring the lock + if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 { + modelSupportEndpointsLock.Lock() + defer modelSupportEndpointsLock.Unlock() + updatePricing() + } + } + return pricingMap +} + +// GetVendors 返回当前定价接口使用到的供应商信息 +func GetVendors() []PricingVendor { + if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 { + // 保证先刷新一次 + GetPricing() + } + return vendorsList +} + +func GetModelSupportEndpointTypes(model string) []constant.EndpointType { + if model == "" { + return make([]constant.EndpointType, 0) + } + modelSupportEndpointsLock.RLock() + defer modelSupportEndpointsLock.RUnlock() + if endpoints, ok := modelSupportEndpointTypes[model]; ok { + return endpoints + } + return make([]constant.EndpointType, 0) +} + +func updatePricing() { + //modelRatios := common.GetModelRatios() + enableAbilities, err := GetAllEnableAbilityWithChannels() + if err != nil { + common.SysLog(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err)) + return + } + // 预加载模型元数据与供应商一次,避免循环查询 + var allMeta []Model + _ = DB.Find(&allMeta).Error + metaMap := make(map[string]*Model) + prefixList := make([]*Model, 0) + suffixList := make([]*Model, 0) + containsList := make([]*Model, 0) + for i := range allMeta { + m := &allMeta[i] + if m.NameRule == NameRuleExact { + metaMap[m.ModelName] = m + } else { + switch m.NameRule { + case NameRulePrefix: + prefixList = append(prefixList, m) + case NameRuleSuffix: + suffixList = append(suffixList, m) + case NameRuleContains: + containsList = append(containsList, m) + } + } + } + + // 将非精确规则模型匹配到 metaMap + for _, m := range prefixList { + for _, pricingModel := range enableAbilities { + if strings.HasPrefix(pricingModel.Model, m.ModelName) { + if _, exists := metaMap[pricingModel.Model]; !exists { + metaMap[pricingModel.Model] = m + } + } + } + } + for _, m := range suffixList { + for _, pricingModel := range enableAbilities { + if strings.HasSuffix(pricingModel.Model, m.ModelName) { + if _, exists := metaMap[pricingModel.Model]; !exists { + metaMap[pricingModel.Model] = m + } + } + } + } + for _, m := range containsList { + for _, pricingModel := range enableAbilities { + if strings.Contains(pricingModel.Model, m.ModelName) { + if _, exists := metaMap[pricingModel.Model]; !exists { + metaMap[pricingModel.Model] = m + } + } + } + } + + // 预加载供应商 + var vendors []Vendor + _ = DB.Find(&vendors).Error + vendorMap := make(map[int]*Vendor) + for i := range vendors { + vendorMap[vendors[i].Id] = &vendors[i] + } + + // 初始化默认供应商映射 + initDefaultVendorMapping(metaMap, vendorMap, enableAbilities) + + // 构建对前端友好的供应商列表 + vendorsList = make([]PricingVendor, 0, len(vendorMap)) + for _, v := range vendorMap { + vendorsList = append(vendorsList, PricingVendor{ + ID: v.Id, + Name: v.Name, + Description: v.Description, + Icon: v.Icon, + }) + } + + modelGroupsMap := make(map[string]*types.Set[string]) + + for _, ability := range enableAbilities { + groups, ok := modelGroupsMap[ability.Model] + if !ok { + groups = types.NewSet[string]() + modelGroupsMap[ability.Model] = groups + } + groups.Add(ability.Group) + } + + //这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点 + modelSupportEndpointsStr := make(map[string][]string) + + // 先根据已有能力填充原生端点 + for _, ability := range enableAbilities { + endpoints := modelSupportEndpointsStr[ability.Model] + channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model) + for _, channelType := range channelTypes { + if !common.StringsContains(endpoints, string(channelType)) { + endpoints = append(endpoints, string(channelType)) + } + } + modelSupportEndpointsStr[ability.Model] = endpoints + } + + // 再补充模型自定义端点 + for modelName, meta := range metaMap { + if strings.TrimSpace(meta.Endpoints) == "" { + continue + } + var raw map[string]interface{} + if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil { + endpoints := modelSupportEndpointsStr[modelName] + for k := range raw { + if !common.StringsContains(endpoints, k) { + endpoints = append(endpoints, k) + } + } + modelSupportEndpointsStr[modelName] = endpoints + } + } + + modelSupportEndpointTypes = make(map[string][]constant.EndpointType) + for model, endpoints := range modelSupportEndpointsStr { + supportedEndpoints := make([]constant.EndpointType, 0) + for _, endpointStr := range endpoints { + endpointType := constant.EndpointType(endpointStr) + supportedEndpoints = append(supportedEndpoints, endpointType) + } + modelSupportEndpointTypes[model] = supportedEndpoints + } + + // 构建全局 supportedEndpointMap(默认 + 自定义覆盖) + supportedEndpointMap = make(map[string]common.EndpointInfo) + // 1. 默认端点 + for _, endpoints := range modelSupportEndpointTypes { + for _, et := range endpoints { + if info, ok := common.GetDefaultEndpointInfo(et); ok { + if _, exists := supportedEndpointMap[string(et)]; !exists { + supportedEndpointMap[string(et)] = info + } + } + } + } + // 2. 自定义端点(models 表)覆盖默认 + for _, meta := range metaMap { + if strings.TrimSpace(meta.Endpoints) == "" { + continue + } + var raw map[string]interface{} + if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil { + for k, v := range raw { + switch val := v.(type) { + case string: + supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"} + case map[string]interface{}: + ep := common.EndpointInfo{Method: "POST"} + if p, ok := val["path"].(string); ok { + ep.Path = p + } + if m, ok := val["method"].(string); ok { + ep.Method = strings.ToUpper(m) + } + supportedEndpointMap[k] = ep + default: + // ignore unsupported types + } + } + } + } + + pricingMap = make([]Pricing, 0) + for model, groups := range modelGroupsMap { + pricing := Pricing{ + ModelName: model, + EnableGroup: groups.Items(), + SupportedEndpointTypes: modelSupportEndpointTypes[model], + } + + // 补充模型元数据(描述、标签、供应商、状态) + if meta, ok := metaMap[model]; ok { + // 若模型被禁用(status!=1),则直接跳过,不返回给前端 + if meta.Status != 1 { + continue + } + pricing.Description = meta.Description + pricing.Icon = meta.Icon + pricing.Tags = meta.Tags + pricing.VendorID = meta.VendorID + } + modelPrice, findPrice := ratio_setting.GetModelPrice(model, false) + if findPrice { + pricing.ModelPrice = modelPrice + pricing.QuotaType = 1 + } else { + modelRatio, _, _ := ratio_setting.GetModelRatio(model) + pricing.ModelRatio = modelRatio + pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model) + pricing.QuotaType = 0 + } + pricingMap = append(pricingMap, pricing) + } + + // 刷新缓存映射,供高并发快速查询 + modelEnableGroupsLock.Lock() + modelEnableGroups = make(map[string][]string) + modelQuotaTypeMap = make(map[string]int) + for _, p := range pricingMap { + modelEnableGroups[p.ModelName] = p.EnableGroup + modelQuotaTypeMap[p.ModelName] = p.QuotaType + } + modelEnableGroupsLock.Unlock() + + lastGetPricingTime = time.Now() +} + +// GetSupportedEndpointMap 返回全局端点到路径的映射 +func GetSupportedEndpointMap() map[string]common.EndpointInfo { + return supportedEndpointMap +} diff --git a/model/pricing_default.go b/model/pricing_default.go new file mode 100644 index 0000000000000000000000000000000000000000..db64cafbb1e43928e1ce66677a0e4c3f2e0340d7 --- /dev/null +++ b/model/pricing_default.go @@ -0,0 +1,128 @@ +package model + +import ( + "strings" +) + +// 简化的供应商映射规则 +var defaultVendorRules = map[string]string{ + "gpt": "OpenAI", + "dall-e": "OpenAI", + "whisper": "OpenAI", + "o1": "OpenAI", + "o3": "OpenAI", + "claude": "Anthropic", + "gemini": "Google", + "moonshot": "Moonshot", + "kimi": "Moonshot", + "chatglm": "智谱", + "glm-": "智谱", + "qwen": "阿里巴巴", + "deepseek": "DeepSeek", + "abab": "MiniMax", + "ernie": "百度", + "spark": "讯飞", + "hunyuan": "腾讯", + "command": "Cohere", + "@cf/": "Cloudflare", + "360": "360", + "yi": "零一万物", + "jina": "Jina", + "mistral": "Mistral", + "grok": "xAI", + "llama": "Meta", + "doubao": "字节跳动", + "kling": "快手", + "jimeng": "即梦", + "vidu": "Vidu", +} + +// 供应商默认图标映射 +var defaultVendorIcons = map[string]string{ + "OpenAI": "OpenAI", + "Anthropic": "Claude.Color", + "Google": "Gemini.Color", + "Moonshot": "Moonshot", + "智谱": "Zhipu.Color", + "阿里巴巴": "Qwen.Color", + "DeepSeek": "DeepSeek.Color", + "MiniMax": "Minimax.Color", + "百度": "Wenxin.Color", + "讯飞": "Spark.Color", + "腾讯": "Hunyuan.Color", + "Cohere": "Cohere.Color", + "Cloudflare": "Cloudflare.Color", + "360": "Ai360.Color", + "零一万物": "Yi.Color", + "Jina": "Jina", + "Mistral": "Mistral.Color", + "xAI": "XAI", + "Meta": "Ollama", + "字节跳动": "Doubao.Color", + "快手": "Kling.Color", + "即梦": "Jimeng.Color", + "Vidu": "Vidu", + "微软": "AzureAI", + "Microsoft": "AzureAI", + "Azure": "AzureAI", +} + +// initDefaultVendorMapping 简化的默认供应商映射 +func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vendor, enableAbilities []AbilityWithChannel) { + for _, ability := range enableAbilities { + modelName := ability.Model + if _, exists := metaMap[modelName]; exists { + continue + } + + // 匹配供应商 + vendorID := 0 + modelLower := strings.ToLower(modelName) + for pattern, vendorName := range defaultVendorRules { + if strings.Contains(modelLower, pattern) { + vendorID = getOrCreateVendor(vendorName, vendorMap) + break + } + } + + // 创建模型元数据 + metaMap[modelName] = &Model{ + ModelName: modelName, + VendorID: vendorID, + Status: 1, + NameRule: NameRuleExact, + } + } +} + +// 查找或创建供应商 +func getOrCreateVendor(vendorName string, vendorMap map[int]*Vendor) int { + // 查找现有供应商 + for id, vendor := range vendorMap { + if vendor.Name == vendorName { + return id + } + } + + // 创建新供应商 + newVendor := &Vendor{ + Name: vendorName, + Status: 1, + Icon: getDefaultVendorIcon(vendorName), + } + + if err := newVendor.Insert(); err != nil { + return 0 + } + + vendorMap[newVendor.Id] = newVendor + return newVendor.Id +} + +// 获取供应商默认图标 +func getDefaultVendorIcon(vendorName string) string { + if icon, exists := defaultVendorIcons[vendorName]; exists { + return icon + } + return "" +} diff --git a/model/pricing_refresh.go b/model/pricing_refresh.go new file mode 100644 index 0000000000000000000000000000000000000000..cd0d75596cb6ba770906a72867a13cfdd90133b7 --- /dev/null +++ b/model/pricing_refresh.go @@ -0,0 +1,14 @@ +package model + +// RefreshPricing 强制立即重新计算与定价相关的缓存。 +// 该方法用于需要最新数据的内部管理 API, +// 因此会绕过默认的 1 分钟延迟刷新。 +func RefreshPricing() { + updatePricingLock.Lock() + defer updatePricingLock.Unlock() + + modelSupportEndpointsLock.Lock() + defer modelSupportEndpointsLock.Unlock() + + updatePricing() +} diff --git a/model/redemption.go b/model/redemption.go new file mode 100644 index 0000000000000000000000000000000000000000..7dd2d952727858761ade9fe206eac9bf3f8ab320 --- /dev/null +++ b/model/redemption.go @@ -0,0 +1,197 @@ +package model + +import ( + "errors" + "fmt" + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + + "gorm.io/gorm" +) + +type Redemption struct { + Id int `json:"id"` + UserId int `json:"user_id"` + Key string `json:"key" gorm:"type:char(32);uniqueIndex"` + Status int `json:"status" gorm:"default:1"` + Name string `json:"name" gorm:"index"` + Quota int `json:"quota" gorm:"default:100"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"` + Count int `json:"count" gorm:"-:all"` // only for api request + UsedUserId int `json:"used_user_id"` + DeletedAt gorm.DeletedAt `gorm:"index"` + ExpiredTime int64 `json:"expired_time" gorm:"bigint"` // 过期时间,0 表示不过期 +} + +func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total int64, err error) { + // 开始事务 + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 获取总数 + err = tx.Model(&Redemption{}).Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // 获取分页数据 + err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // 提交事务 + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return redemptions, total, nil +} + +func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Redemption, total int64, err error) { + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // Build query based on keyword type + query := tx.Model(&Redemption{}) + + // 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+"%") + } + + // Get total count + err = query.Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // Get paginated data + err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return redemptions, total, nil +} + +func GetRedemptionById(id int) (*Redemption, error) { + if id == 0 { + return nil, errors.New("id 为空!") + } + redemption := Redemption{Id: id} + var err error = nil + err = DB.First(&redemption, "id = ?", id).Error + return &redemption, err +} + +func Redeem(key string, userId int) (quota int, err error) { + if key == "" { + return 0, errors.New("未提供兑换码") + } + if userId == 0 { + return 0, errors.New("无效的 user id") + } + redemption := &Redemption{} + + keyCol := "`key`" + if common.UsingPostgreSQL { + keyCol = `"key"` + } + common.RandomSleep() + err = DB.Transaction(func(tx *gorm.DB) error { + err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error + if err != nil { + return errors.New("无效的兑换码") + } + if redemption.Status != common.RedemptionCodeStatusEnabled { + return errors.New("该兑换码已被使用") + } + if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() { + return errors.New("该兑换码已过期") + } + err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error + if err != nil { + return err + } + redemption.RedeemedTime = common.GetTimestamp() + redemption.Status = common.RedemptionCodeStatusUsed + redemption.UsedUserId = userId + err = tx.Save(redemption).Error + return err + }) + if err != nil { + return 0, errors.New("兑换失败," + err.Error()) + } + RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s,兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id)) + return redemption.Quota, nil +} + +func (redemption *Redemption) Insert() error { + var err error + err = DB.Create(redemption).Error + return err +} + +func (redemption *Redemption) SelectUpdate() error { + // This can update zero values + return DB.Model(redemption).Select("redeemed_time", "status").Updates(redemption).Error +} + +// Update Make sure your token's fields is completed, because this will update non-zero values +func (redemption *Redemption) Update() error { + var err error + err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error + return err +} + +func (redemption *Redemption) Delete() error { + var err error + err = DB.Delete(redemption).Error + return err +} + +func DeleteRedemptionById(id int) (err error) { + if id == 0 { + return errors.New("id 为空!") + } + redemption := Redemption{Id: id} + err = DB.Where(redemption).First(&redemption).Error + if err != nil { + return err + } + return redemption.Delete() +} + +func DeleteInvalidRedemptions() (int64, error) { + now := common.GetTimestamp() + result := DB.Where("status IN ? OR (status = ? AND expired_time != 0 AND expired_time < ?)", []int{common.RedemptionCodeStatusUsed, common.RedemptionCodeStatusDisabled}, common.RedemptionCodeStatusEnabled, now).Delete(&Redemption{}) + return result.RowsAffected, result.Error +} diff --git a/model/setup.go b/model/setup.go new file mode 100644 index 0000000000000000000000000000000000000000..c4d7997f0cc5b09cee915fcecc139cfa6c80314e --- /dev/null +++ b/model/setup.go @@ -0,0 +1,16 @@ +package model + +type Setup struct { + ID uint `json:"id" gorm:"primaryKey"` + Version string `json:"version" gorm:"type:varchar(50);not null"` + InitializedAt int64 `json:"initialized_at" gorm:"type:bigint;not null"` +} + +func GetSetup() *Setup { + var setup Setup + err := DB.First(&setup).Error + if err != nil { + return nil + } + return &setup +} diff --git a/model/task.go b/model/task.go new file mode 100644 index 0000000000000000000000000000000000000000..c76d26edfc3e53dbb177a7d68b076e62a090df1d --- /dev/null +++ b/model/task.go @@ -0,0 +1,442 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "time" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + commonRelay "github.com/QuantumNous/new-api/relay/common" +) + +type TaskStatus string + +func (t TaskStatus) ToVideoStatus() string { + var status string + switch t { + case TaskStatusQueued, TaskStatusSubmitted: + status = dto.VideoStatusQueued + case TaskStatusInProgress: + status = dto.VideoStatusInProgress + case TaskStatusSuccess: + status = dto.VideoStatusCompleted + case TaskStatusFailure: + status = dto.VideoStatusFailed + default: + status = dto.VideoStatusUnknown // Default fallback + } + return status +} + +const ( + TaskStatusNotStart TaskStatus = "NOT_START" + TaskStatusSubmitted = "SUBMITTED" + TaskStatusQueued = "QUEUED" + TaskStatusInProgress = "IN_PROGRESS" + TaskStatusFailure = "FAILURE" + TaskStatusSuccess = "SUCCESS" + TaskStatusUnknown = "UNKNOWN" +) + +type Task struct { + ID int64 `json:"id" gorm:"primary_key;AUTO_INCREMENT"` + CreatedAt int64 `json:"created_at" gorm:"index"` + UpdatedAt int64 `json:"updated_at"` + TaskID string `json:"task_id" gorm:"type:varchar(191);index"` // 第三方id,不一定有/ song id\ Task id + Platform constant.TaskPlatform `json:"platform" gorm:"type:varchar(30);index"` // 平台 + UserId int `json:"user_id" gorm:"index"` + Group string `json:"group" gorm:"type:varchar(50)"` // 修正计费用 + ChannelId int `json:"channel_id" gorm:"index"` + Quota int `json:"quota"` + Action string `json:"action" gorm:"type:varchar(40);index"` // 任务类型, song, lyrics, description-mode + Status TaskStatus `json:"status" gorm:"type:varchar(20);index"` // 任务状态 + FailReason string `json:"fail_reason"` + SubmitTime int64 `json:"submit_time" gorm:"index"` + StartTime int64 `json:"start_time" gorm:"index"` + FinishTime int64 `json:"finish_time" gorm:"index"` + Progress string `json:"progress" gorm:"type:varchar(20);index"` + Properties Properties `json:"properties" gorm:"type:json"` + // 禁止返回给用户,内部可能包含key等隐私信息 + PrivateData TaskPrivateData `json:"-" gorm:"column:private_data;type:json"` + Data json.RawMessage `json:"data" gorm:"type:json"` +} + +func (t *Task) SetData(data any) { + b, _ := json.Marshal(data) + t.Data = json.RawMessage(b) +} + +func (t *Task) GetData(v any) error { + err := json.Unmarshal(t.Data, &v) + return err +} + +type Properties struct { + Input string `json:"input"` + UpstreamModelName string `json:"upstream_model_name,omitempty"` + OriginModelName string `json:"origin_model_name,omitempty"` +} + +func (m *Properties) Scan(val interface{}) error { + bytesValue, _ := val.([]byte) + if len(bytesValue) == 0 { + *m = Properties{} + return nil + } + return json.Unmarshal(bytesValue, m) +} + +func (m Properties) Value() (driver.Value, error) { + if m == (Properties{}) { + return nil, nil + } + return json.Marshal(m) +} + +type TaskPrivateData struct { + Key string `json:"key,omitempty"` +} + +func (p *TaskPrivateData) Scan(val interface{}) error { + bytesValue, _ := val.([]byte) + if len(bytesValue) == 0 { + return nil + } + return json.Unmarshal(bytesValue, p) +} + +func (p TaskPrivateData) Value() (driver.Value, error) { + if (p == TaskPrivateData{}) { + return nil, nil + } + return json.Marshal(p) +} + +// SyncTaskQueryParams 用于包含所有搜索条件的结构体,可以根据需求添加更多字段 +type SyncTaskQueryParams struct { + Platform constant.TaskPlatform + ChannelID string + TaskID string + UserID string + Action string + Status string + StartTimestamp int64 + EndTimestamp int64 + UserIDs []int +} + +func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.RelayInfo) *Task { + properties := Properties{} + privateData := TaskPrivateData{} + if relayInfo != nil && relayInfo.ChannelMeta != nil { + if relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeGemini { + privateData.Key = relayInfo.ChannelMeta.ApiKey + } + if relayInfo.UpstreamModelName != "" { + properties.UpstreamModelName = relayInfo.UpstreamModelName + } + if relayInfo.OriginModelName != "" { + properties.OriginModelName = relayInfo.OriginModelName + } + } + + t := &Task{ + UserId: relayInfo.UserId, + Group: relayInfo.UsingGroup, + SubmitTime: time.Now().Unix(), + Status: TaskStatusNotStart, + Progress: "0%", + ChannelId: relayInfo.ChannelId, + Platform: platform, + Properties: properties, + PrivateData: privateData, + } + return t +} + +func TaskGetAllUserTask(userId int, startIdx int, num int, queryParams SyncTaskQueryParams) []*Task { + var tasks []*Task + var err error + + // 初始化查询构建器 + query := DB.Where("user_id = ?", userId) + + if queryParams.TaskID != "" { + query = query.Where("task_id = ?", queryParams.TaskID) + } + if queryParams.Action != "" { + query = query.Where("action = ?", queryParams.Action) + } + if queryParams.Status != "" { + query = query.Where("status = ?", queryParams.Status) + } + if queryParams.Platform != "" { + query = query.Where("platform = ?", queryParams.Platform) + } + if queryParams.StartTimestamp != 0 { + // 假设您已将前端传来的时间戳转换为数据库所需的时间格式,并处理了时间戳的验证和解析 + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != 0 { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + + // 获取数据 + err = query.Omit("channel_id").Order("id desc").Limit(num).Offset(startIdx).Find(&tasks).Error + if err != nil { + return nil + } + + return tasks +} + +func TaskGetAllTasks(startIdx int, num int, queryParams SyncTaskQueryParams) []*Task { + var tasks []*Task + var err error + + // 初始化查询构建器 + query := DB + + // 添加过滤条件 + if queryParams.ChannelID != "" { + query = query.Where("channel_id = ?", queryParams.ChannelID) + } + if queryParams.Platform != "" { + query = query.Where("platform = ?", queryParams.Platform) + } + if queryParams.UserID != "" { + query = query.Where("user_id = ?", queryParams.UserID) + } + if len(queryParams.UserIDs) != 0 { + query = query.Where("user_id in (?)", queryParams.UserIDs) + } + if queryParams.TaskID != "" { + query = query.Where("task_id = ?", queryParams.TaskID) + } + if queryParams.Action != "" { + query = query.Where("action = ?", queryParams.Action) + } + if queryParams.Status != "" { + query = query.Where("status = ?", queryParams.Status) + } + if queryParams.StartTimestamp != 0 { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != 0 { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + + // 获取数据 + err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&tasks).Error + if err != nil { + return nil + } + + return tasks +} + +func GetAllUnFinishSyncTasks(limit int) []*Task { + var tasks []*Task + var err error + // get all tasks progress is not 100% + err = DB.Where("progress != ?", "100%").Where("status != ?", TaskStatusFailure).Where("status != ?", TaskStatusSuccess).Limit(limit).Order("id").Find(&tasks).Error + if err != nil { + return nil + } + return tasks +} + +func GetByOnlyTaskId(taskId string) (*Task, bool, error) { + if taskId == "" { + return nil, false, nil + } + var task *Task + var err error + err = DB.Where("task_id = ?", taskId).First(&task).Error + exist, err := RecordExist(err) + if err != nil { + return nil, false, err + } + return task, exist, err +} + +func GetByTaskId(userId int, taskId string) (*Task, bool, error) { + if taskId == "" { + return nil, false, nil + } + var task *Task + var err error + err = DB.Where("user_id = ? and task_id = ?", userId, taskId). + First(&task).Error + exist, err := RecordExist(err) + if err != nil { + return nil, false, err + } + return task, exist, err +} + +func GetByTaskIds(userId int, taskIds []any) ([]*Task, error) { + if len(taskIds) == 0 { + return nil, nil + } + var task []*Task + var err error + err = DB.Where("user_id = ? and task_id in (?)", userId, taskIds). + Find(&task).Error + if err != nil { + return nil, err + } + return task, nil +} + +func TaskUpdateProgress(id int64, progress string) error { + return DB.Model(&Task{}).Where("id = ?", id).Update("progress", progress).Error +} + +func (Task *Task) Insert() error { + var err error + err = DB.Create(Task).Error + return err +} + +func (Task *Task) Update() error { + var err error + err = DB.Save(Task).Error + return err +} + +func TaskBulkUpdate(TaskIds []string, params map[string]any) error { + if len(TaskIds) == 0 { + return nil + } + return DB.Model(&Task{}). + Where("task_id in (?)", TaskIds). + Updates(params).Error +} + +func TaskBulkUpdateByTaskIds(taskIDs []int64, params map[string]any) error { + if len(taskIDs) == 0 { + return nil + } + return DB.Model(&Task{}). + Where("id in (?)", taskIDs). + Updates(params).Error +} + +func TaskBulkUpdateByID(ids []int64, params map[string]any) error { + if len(ids) == 0 { + return nil + } + return DB.Model(&Task{}). + Where("id in (?)", ids). + Updates(params).Error +} + +type TaskQuotaUsage struct { + Mode string `json:"mode"` + Count float64 `json:"count"` +} + +func SumUsedTaskQuota(queryParams SyncTaskQueryParams) (stat []TaskQuotaUsage, err error) { + query := DB.Model(Task{}) + // 添加过滤条件 + if queryParams.ChannelID != "" { + query = query.Where("channel_id = ?", queryParams.ChannelID) + } + if queryParams.UserID != "" { + query = query.Where("user_id = ?", queryParams.UserID) + } + if len(queryParams.UserIDs) != 0 { + query = query.Where("user_id in (?)", queryParams.UserIDs) + } + if queryParams.TaskID != "" { + query = query.Where("task_id = ?", queryParams.TaskID) + } + if queryParams.Action != "" { + query = query.Where("action = ?", queryParams.Action) + } + if queryParams.Status != "" { + query = query.Where("status = ?", queryParams.Status) + } + if queryParams.StartTimestamp != 0 { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != 0 { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + err = query.Select("mode, sum(quota) as count").Group("mode").Find(&stat).Error + return stat, err +} + +// TaskCountAllTasks returns total tasks that match the given query params (admin usage) +func TaskCountAllTasks(queryParams SyncTaskQueryParams) int64 { + var total int64 + query := DB.Model(&Task{}) + if queryParams.ChannelID != "" { + query = query.Where("channel_id = ?", queryParams.ChannelID) + } + if queryParams.Platform != "" { + query = query.Where("platform = ?", queryParams.Platform) + } + if queryParams.UserID != "" { + query = query.Where("user_id = ?", queryParams.UserID) + } + if len(queryParams.UserIDs) != 0 { + query = query.Where("user_id in (?)", queryParams.UserIDs) + } + if queryParams.TaskID != "" { + query = query.Where("task_id = ?", queryParams.TaskID) + } + if queryParams.Action != "" { + query = query.Where("action = ?", queryParams.Action) + } + if queryParams.Status != "" { + query = query.Where("status = ?", queryParams.Status) + } + if queryParams.StartTimestamp != 0 { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != 0 { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + _ = query.Count(&total).Error + return total +} + +// TaskCountAllUserTask returns total tasks for given user +func TaskCountAllUserTask(userId int, queryParams SyncTaskQueryParams) int64 { + var total int64 + query := DB.Model(&Task{}).Where("user_id = ?", userId) + if queryParams.TaskID != "" { + query = query.Where("task_id = ?", queryParams.TaskID) + } + if queryParams.Action != "" { + query = query.Where("action = ?", queryParams.Action) + } + if queryParams.Status != "" { + query = query.Where("status = ?", queryParams.Status) + } + if queryParams.Platform != "" { + query = query.Where("platform = ?", queryParams.Platform) + } + if queryParams.StartTimestamp != 0 { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != 0 { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + _ = query.Count(&total).Error + return total +} +func (t *Task) ToOpenAIVideo() *dto.OpenAIVideo { + openAIVideo := dto.NewOpenAIVideo() + openAIVideo.ID = t.TaskID + openAIVideo.Status = t.Status.ToVideoStatus() + openAIVideo.Model = t.Properties.OriginModelName + openAIVideo.SetProgressStr(t.Progress) + openAIVideo.CreatedAt = t.CreatedAt + openAIVideo.CompletedAt = t.UpdatedAt + openAIVideo.SetMetadata("url", t.FailReason) + return openAIVideo +} diff --git a/model/token.go b/model/token.go new file mode 100644 index 0000000000000000000000000000000000000000..c1fe2a6700dc5e6eb06bb61a062168535a54978f --- /dev/null +++ b/model/token.go @@ -0,0 +1,364 @@ +package model + +import ( + "errors" + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" + + "github.com/bytedance/gopkg/util/gopool" + "gorm.io/gorm" +) + +type Token struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + Key string `json:"key" gorm:"type:char(48);uniqueIndex"` + Status int `json:"status" gorm:"default:1"` + Name string `json:"name" gorm:"index" ` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + AccessedTime int64 `json:"accessed_time" gorm:"bigint"` + ExpiredTime int64 `json:"expired_time" gorm:"bigint;default:-1"` // -1 means never expired + RemainQuota int `json:"remain_quota" gorm:"default:0"` + UnlimitedQuota bool `json:"unlimited_quota"` + ModelLimitsEnabled bool `json:"model_limits_enabled"` + ModelLimits string `json:"model_limits" gorm:"type:varchar(1024);default:''"` + AllowIps *string `json:"allow_ips" gorm:"default:''"` + UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota + Group string `json:"group" gorm:"default:''"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +func (token *Token) Clean() { + token.Key = "" +} + +func (token *Token) GetIpLimitsMap() map[string]any { + // delete empty spaces + //split with \n + ipLimitsMap := make(map[string]any) + if token.AllowIps == nil { + return ipLimitsMap + } + cleanIps := strings.ReplaceAll(*token.AllowIps, " ", "") + if cleanIps == "" { + return ipLimitsMap + } + ips := strings.Split(cleanIps, "\n") + for _, ip := range ips { + ip = strings.TrimSpace(ip) + ip = strings.ReplaceAll(ip, ",", "") + if common.IsIP(ip) { + ipLimitsMap[ip] = true + } + } + return ipLimitsMap +} + +func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) { + var tokens []*Token + var err error + err = DB.Where("user_id = ?", userId).Order("id desc").Limit(num).Offset(startIdx).Find(&tokens).Error + return tokens, err +} + +func SearchUserTokens(userId int, keyword string, token string) (tokens []*Token, err error) { + if token != "" { + token = strings.Trim(token, "sk-") + } + err = DB.Where("user_id = ?", userId).Where("name LIKE ?", "%"+keyword+"%").Where(commonKeyCol+" LIKE ?", "%"+token+"%").Find(&tokens).Error + return tokens, err +} + +func ValidateUserToken(key string) (token *Token, err error) { + if key == "" { + return nil, errors.New("未提供令牌") + } + token, err = GetTokenByKey(key, false) + if err == nil { + if token.Status == common.TokenStatusExhausted { + keyPrefix := key[:3] + keySuffix := key[len(key)-3:] + return token, errors.New("该令牌额度已用尽 TokenStatusExhausted[sk-" + keyPrefix + "***" + keySuffix + "]") + } else if token.Status == common.TokenStatusExpired { + return token, errors.New("该令牌已过期") + } + if token.Status != common.TokenStatusEnabled { + return token, errors.New("该令牌状态不可用") + } + if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() { + if !common.RedisEnabled { + token.Status = common.TokenStatusExpired + err := token.SelectUpdate() + if err != nil { + common.SysLog("failed to update token status" + err.Error()) + } + } + return token, errors.New("该令牌已过期") + } + if !token.UnlimitedQuota && token.RemainQuota <= 0 { + if !common.RedisEnabled { + // in this case, we can make sure the token is exhausted + token.Status = common.TokenStatusExhausted + err := token.SelectUpdate() + if err != nil { + common.SysLog("failed to update token status" + err.Error()) + } + } + keyPrefix := key[:3] + keySuffix := key[len(key)-3:] + return token, errors.New(fmt.Sprintf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota)) + } + return token, nil + } + return nil, errors.New("无效的令牌") +} + +func GetTokenByIds(id int, userId int) (*Token, error) { + if id == 0 || userId == 0 { + return nil, errors.New("id 或 userId 为空!") + } + token := Token{Id: id, UserId: userId} + var err error = nil + err = DB.First(&token, "id = ? and user_id = ?", id, userId).Error + return &token, err +} + +func GetTokenById(id int) (*Token, error) { + if id == 0 { + return nil, errors.New("id 为空!") + } + token := Token{Id: id} + var err error = nil + err = DB.First(&token, "id = ?", id).Error + if shouldUpdateRedis(true, err) { + gopool.Go(func() { + if err := cacheSetToken(token); err != nil { + common.SysLog("failed to update user status cache: " + err.Error()) + } + }) + } + return &token, err +} + +func GetTokenByKey(key string, fromDB bool) (token *Token, err error) { + defer func() { + // Update Redis cache asynchronously on successful DB read + if shouldUpdateRedis(fromDB, err) && token != nil { + gopool.Go(func() { + if err := cacheSetToken(*token); err != nil { + common.SysLog("failed to update user status cache: " + err.Error()) + } + }) + } + }() + if !fromDB && common.RedisEnabled { + // Try Redis first + token, err := cacheGetTokenByKey(key) + if err == nil { + return token, nil + } + // Don't return error - fall through to DB + } + fromDB = true + err = DB.Where(commonKeyCol+" = ?", key).First(&token).Error + return token, err +} + +func (token *Token) Insert() error { + var err error + err = DB.Create(token).Error + return err +} + +// Update Make sure your token's fields is completed, because this will update non-zero values +func (token *Token) Update() (err error) { + defer func() { + if shouldUpdateRedis(true, err) { + gopool.Go(func() { + err := cacheSetToken(*token) + if err != nil { + common.SysLog("failed to update token cache: " + err.Error()) + } + }) + } + }() + err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota", + "model_limits_enabled", "model_limits", "allow_ips", "group").Updates(token).Error + return err +} + +func (token *Token) SelectUpdate() (err error) { + defer func() { + if shouldUpdateRedis(true, err) { + gopool.Go(func() { + err := cacheSetToken(*token) + if err != nil { + common.SysLog("failed to update token cache: " + err.Error()) + } + }) + } + }() + // This can update zero values + return DB.Model(token).Select("accessed_time", "status").Updates(token).Error +} + +func (token *Token) Delete() (err error) { + defer func() { + if shouldUpdateRedis(true, err) { + gopool.Go(func() { + err := cacheDeleteToken(token.Key) + if err != nil { + common.SysLog("failed to delete token cache: " + err.Error()) + } + }) + } + }() + err = DB.Delete(token).Error + return err +} + +func (token *Token) IsModelLimitsEnabled() bool { + return token.ModelLimitsEnabled +} + +func (token *Token) GetModelLimits() []string { + if token.ModelLimits == "" { + return []string{} + } + return strings.Split(token.ModelLimits, ",") +} + +func (token *Token) GetModelLimitsMap() map[string]bool { + limits := token.GetModelLimits() + limitsMap := make(map[string]bool) + for _, limit := range limits { + limitsMap[limit] = true + } + return limitsMap +} + +func DisableModelLimits(tokenId int) error { + token, err := GetTokenById(tokenId) + if err != nil { + return err + } + token.ModelLimitsEnabled = false + token.ModelLimits = "" + return token.Update() +} + +func DeleteTokenById(id int, userId int) (err error) { + // Why we need userId here? In case user want to delete other's token. + if id == 0 || userId == 0 { + return errors.New("id 或 userId 为空!") + } + token := Token{Id: id, UserId: userId} + err = DB.Where(token).First(&token).Error + if err != nil { + return err + } + return token.Delete() +} + +func IncreaseTokenQuota(id int, key string, quota int) (err error) { + if quota < 0 { + return errors.New("quota 不能为负数!") + } + if common.RedisEnabled { + gopool.Go(func() { + err := cacheIncrTokenQuota(key, int64(quota)) + if err != nil { + common.SysLog("failed to increase token quota: " + err.Error()) + } + }) + } + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeTokenQuota, id, quota) + return nil + } + return increaseTokenQuota(id, quota) +} + +func increaseTokenQuota(id int, quota int) (err error) { + err = DB.Model(&Token{}).Where("id = ?", id).Updates( + map[string]interface{}{ + "remain_quota": gorm.Expr("remain_quota + ?", quota), + "used_quota": gorm.Expr("used_quota - ?", quota), + "accessed_time": common.GetTimestamp(), + }, + ).Error + return err +} + +func DecreaseTokenQuota(id int, key string, quota int) (err error) { + if quota < 0 { + return errors.New("quota 不能为负数!") + } + if common.RedisEnabled { + gopool.Go(func() { + err := cacheDecrTokenQuota(key, int64(quota)) + if err != nil { + common.SysLog("failed to decrease token quota: " + err.Error()) + } + }) + } + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeTokenQuota, id, -quota) + return nil + } + return decreaseTokenQuota(id, quota) +} + +func decreaseTokenQuota(id int, quota int) (err error) { + err = DB.Model(&Token{}).Where("id = ?", id).Updates( + map[string]interface{}{ + "remain_quota": gorm.Expr("remain_quota - ?", quota), + "used_quota": gorm.Expr("used_quota + ?", quota), + "accessed_time": common.GetTimestamp(), + }, + ).Error + return err +} + +// CountUserTokens returns total number of tokens for the given user, used for pagination +func CountUserTokens(userId int) (int64, error) { + var total int64 + err := DB.Model(&Token{}).Where("user_id = ?", userId).Count(&total).Error + return total, err +} + +// BatchDeleteTokens 删除指定用户的一组令牌,返回成功删除数量 +func BatchDeleteTokens(ids []int, userId int) (int, error) { + if len(ids) == 0 { + return 0, errors.New("ids 不能为空!") + } + + tx := DB.Begin() + + var tokens []Token + if err := tx.Where("user_id = ? AND id IN (?)", userId, ids).Find(&tokens).Error; err != nil { + tx.Rollback() + return 0, err + } + + if err := tx.Where("user_id = ? AND id IN (?)", userId, ids).Delete(&Token{}).Error; err != nil { + tx.Rollback() + return 0, err + } + + if err := tx.Commit().Error; err != nil { + return 0, err + } + + if common.RedisEnabled { + gopool.Go(func() { + for _, t := range tokens { + _ = cacheDeleteToken(t.Key) + } + }) + } + + return len(tokens), nil +} diff --git a/model/token_cache.go b/model/token_cache.go new file mode 100644 index 0000000000000000000000000000000000000000..947f587d670c0cdfb0b52639f6ac33fa46328b46 --- /dev/null +++ b/model/token_cache.go @@ -0,0 +1,65 @@ +package model + +import ( + "fmt" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" +) + +func cacheSetToken(token Token) error { + key := common.GenerateHMAC(token.Key) + token.Clean() + err := common.RedisHSetObj(fmt.Sprintf("token:%s", key), &token, time.Duration(common.RedisKeyCacheSeconds())*time.Second) + if err != nil { + return err + } + return nil +} + +func cacheDeleteToken(key string) error { + key = common.GenerateHMAC(key) + err := common.RedisDelKey(fmt.Sprintf("token:%s", key)) + if err != nil { + return err + } + return nil +} + +func cacheIncrTokenQuota(key string, increment int64) error { + key = common.GenerateHMAC(key) + err := common.RedisHIncrBy(fmt.Sprintf("token:%s", key), constant.TokenFiledRemainQuota, increment) + if err != nil { + return err + } + return nil +} + +func cacheDecrTokenQuota(key string, decrement int64) error { + return cacheIncrTokenQuota(key, -decrement) +} + +func cacheSetTokenField(key string, field string, value string) error { + key = common.GenerateHMAC(key) + err := common.RedisHSetField(fmt.Sprintf("token:%s", key), field, value) + if err != nil { + return err + } + return nil +} + +// CacheGetTokenByKey 从缓存中获取 token,如果缓存中不存在,则从数据库中获取 +func cacheGetTokenByKey(key string) (*Token, error) { + hmacKey := common.GenerateHMAC(key) + if !common.RedisEnabled { + return nil, fmt.Errorf("redis is not enabled") + } + var token Token + err := common.RedisHGetObj(fmt.Sprintf("token:%s", hmacKey), &token) + if err != nil { + return nil, err + } + token.Key = key + return &token, nil +} diff --git a/model/topup.go b/model/topup.go new file mode 100644 index 0000000000000000000000000000000000000000..994094d9d14cc6ca26d7a69f3f9b5ffee4117fdf --- /dev/null +++ b/model/topup.go @@ -0,0 +1,376 @@ +package model + +import ( + "errors" + "fmt" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +type TopUp struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + Amount int64 `json:"amount"` + Money float64 `json:"money"` + TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"` + PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"` + CreateTime int64 `json:"create_time"` + CompleteTime int64 `json:"complete_time"` + Status string `json:"status"` +} + +func (topUp *TopUp) Insert() error { + var err error + err = DB.Create(topUp).Error + return err +} + +func (topUp *TopUp) Update() error { + var err error + err = DB.Save(topUp).Error + return err +} + +func GetTopUpById(id int) *TopUp { + var topUp *TopUp + var err error + err = DB.Where("id = ?", id).First(&topUp).Error + if err != nil { + return nil + } + return topUp +} + +func GetTopUpByTradeNo(tradeNo string) *TopUp { + var topUp *TopUp + var err error + err = DB.Where("trade_no = ?", tradeNo).First(&topUp).Error + if err != nil { + return nil + } + return topUp +} + +func Recharge(referenceId string, customerId string) (err error) { + if referenceId == "" { + return errors.New("未提供支付单号") + } + + var quota float64 + topUp := &TopUp{} + + refCol := "`trade_no`" + if common.UsingPostgreSQL { + refCol = `"trade_no"` + } + + err = DB.Transaction(func(tx *gorm.DB) error { + err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error + if err != nil { + return errors.New("充值订单不存在") + } + + if topUp.Status != common.TopUpStatusPending { + return errors.New("充值订单状态错误") + } + + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + err = tx.Save(topUp).Error + if err != nil { + return err + } + + quota = topUp.Money * common.QuotaPerUnit + err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error + if err != nil { + return err + } + + return nil + }) + + if err != nil { + return errors.New("充值失败," + err.Error()) + } + + RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount)) + + return nil +} + +func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) { + // Start transaction + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // Get total count within transaction + err = tx.Model(&TopUp{}).Where("user_id = ?", userId).Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // Get paginated topups within same transaction + err = tx.Where("user_id = ?", userId).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // Commit transaction + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return topups, total, nil +} + +// GetAllTopUps 获取全平台的充值记录(管理员使用) +func GetAllTopUps(pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) { + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + if err = tx.Model(&TopUp{}).Count(&total).Error; err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return topups, total, nil +} + +// SearchUserTopUps 按订单号搜索某用户的充值记录 +func SearchUserTopUps(userId int, keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) { + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + query := tx.Model(&TopUp{}).Where("user_id = ?", userId) + if keyword != "" { + like := "%%" + keyword + "%%" + query = query.Where("trade_no LIKE ?", like) + } + + if err = query.Count(&total).Error; err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + return topups, total, nil +} + +// SearchAllTopUps 按订单号搜索全平台充值记录(管理员使用) +func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) { + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + query := tx.Model(&TopUp{}) + if keyword != "" { + like := "%%" + keyword + "%%" + query = query.Where("trade_no LIKE ?", like) + } + + if err = query.Count(&total).Error; err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + return topups, total, nil +} + +// ManualCompleteTopUp 管理员手动完成订单并给用户充值 +func ManualCompleteTopUp(tradeNo string) error { + if tradeNo == "" { + return errors.New("未提供订单号") + } + + refCol := "`trade_no`" + if common.UsingPostgreSQL { + refCol = `"trade_no"` + } + + var userId int + var quotaToAdd int + var payMoney float64 + + err := DB.Transaction(func(tx *gorm.DB) error { + topUp := &TopUp{} + // 行级锁,避免并发补单 + if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil { + return errors.New("充值订单不存在") + } + + // 幂等处理:已成功直接返回 + if topUp.Status == common.TopUpStatusSuccess { + return nil + } + + if topUp.Status != common.TopUpStatusPending { + return errors.New("订单状态不是待支付,无法补单") + } + + // 计算应充值额度: + // - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit + // - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit + if topUp.PaymentMethod == "stripe" { + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart()) + } else { + dAmount := decimal.NewFromInt(topUp.Amount) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart()) + } + if quotaToAdd <= 0 { + return errors.New("无效的充值额度") + } + + // 标记完成 + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + if err := tx.Save(topUp).Error; err != nil { + return err + } + + // 增加用户额度(立即写库,保持一致性) + if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { + return err + } + + userId = topUp.UserId + payMoney = topUp.Money + return nil + }) + + if err != nil { + return err + } + + // 事务外记录日志,避免阻塞 + RecordLog(userId, LogTypeTopup, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney)) + return nil +} +func RechargeCreem(referenceId string, customerEmail string, customerName string) (err error) { + if referenceId == "" { + return errors.New("未提供支付单号") + } + + var quota int64 + topUp := &TopUp{} + + refCol := "`trade_no`" + if common.UsingPostgreSQL { + refCol = `"trade_no"` + } + + err = DB.Transaction(func(tx *gorm.DB) error { + err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error + if err != nil { + return errors.New("充值订单不存在") + } + + if topUp.Status != common.TopUpStatusPending { + return errors.New("充值订单状态错误") + } + + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + err = tx.Save(topUp).Error + if err != nil { + return err + } + + // Creem 直接使用 Amount 作为充值额度(整数) + quota = topUp.Amount + + // 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名 + updateFields := map[string]interface{}{ + "quota": gorm.Expr("quota + ?", quota), + } + + // 如果有客户邮箱,尝试更新用户邮箱(仅当用户邮箱为空时) + if customerEmail != "" { + // 先检查用户当前邮箱是否为空 + var user User + err = tx.Where("id = ?", topUp.UserId).First(&user).Error + if err != nil { + return err + } + + // 如果用户邮箱为空,则更新为支付时使用的邮箱 + if user.Email == "" { + updateFields["email"] = customerEmail + } + } + + err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields).Error + if err != nil { + return err + } + + return nil + }) + + if err != nil { + return errors.New("充值失败," + err.Error()) + } + + RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money)) + + return nil +} diff --git a/model/twofa.go b/model/twofa.go new file mode 100644 index 0000000000000000000000000000000000000000..e63c66629d7af3f24ddd28c2f84f0349e4ef440b --- /dev/null +++ b/model/twofa.go @@ -0,0 +1,323 @@ +package model + +import ( + "errors" + "fmt" + "time" + + "github.com/QuantumNous/new-api/common" + + "gorm.io/gorm" +) + +var ErrTwoFANotEnabled = errors.New("用户未启用2FA") + +// TwoFA 用户2FA设置表 +type TwoFA struct { + Id int `json:"id" gorm:"primaryKey"` + UserId int `json:"user_id" gorm:"unique;not null;index"` + Secret string `json:"-" gorm:"type:varchar(255);not null"` // TOTP密钥,不返回给前端 + IsEnabled bool `json:"is_enabled"` + FailedAttempts int `json:"failed_attempts" gorm:"default:0"` + LockedUntil *time.Time `json:"locked_until,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// TwoFABackupCode 备用码使用记录表 +type TwoFABackupCode struct { + Id int `json:"id" gorm:"primaryKey"` + UserId int `json:"user_id" gorm:"not null;index"` + CodeHash string `json:"-" gorm:"type:varchar(255);not null"` // 备用码哈希 + IsUsed bool `json:"is_used"` + UsedAt *time.Time `json:"used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// GetTwoFAByUserId 根据用户ID获取2FA设置 +func GetTwoFAByUserId(userId int) (*TwoFA, error) { + if userId == 0 { + return nil, errors.New("用户ID不能为空") + } + + var twoFA TwoFA + err := DB.Where("user_id = ?", userId).First(&twoFA).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil // 返回nil表示未设置2FA + } + return nil, err + } + + return &twoFA, nil +} + +// IsTwoFAEnabled 检查用户是否启用了2FA +func IsTwoFAEnabled(userId int) bool { + twoFA, err := GetTwoFAByUserId(userId) + if err != nil || twoFA == nil { + return false + } + return twoFA.IsEnabled +} + +// CreateTwoFA 创建2FA设置 +func (t *TwoFA) Create() error { + // 检查用户是否已存在2FA设置 + existing, err := GetTwoFAByUserId(t.UserId) + if err != nil { + return err + } + if existing != nil { + return errors.New("用户已存在2FA设置") + } + + // 验证用户存在 + var user User + if err := DB.First(&user, t.UserId).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("用户不存在") + } + return err + } + + return DB.Create(t).Error +} + +// Update 更新2FA设置 +func (t *TwoFA) Update() error { + if t.Id == 0 { + return errors.New("2FA记录ID不能为空") + } + return DB.Save(t).Error +} + +// Delete 删除2FA设置 +func (t *TwoFA) Delete() error { + if t.Id == 0 { + return errors.New("2FA记录ID不能为空") + } + + // 使用事务确保原子性 + return DB.Transaction(func(tx *gorm.DB) error { + // 同时删除相关的备用码记录(硬删除) + if err := tx.Unscoped().Where("user_id = ?", t.UserId).Delete(&TwoFABackupCode{}).Error; err != nil { + return err + } + + // 硬删除2FA记录 + return tx.Unscoped().Delete(t).Error + }) +} + +// ResetFailedAttempts 重置失败尝试次数 +func (t *TwoFA) ResetFailedAttempts() error { + t.FailedAttempts = 0 + t.LockedUntil = nil + return t.Update() +} + +// IncrementFailedAttempts 增加失败尝试次数 +func (t *TwoFA) IncrementFailedAttempts() error { + t.FailedAttempts++ + + // 检查是否需要锁定 + if t.FailedAttempts >= common.MaxFailAttempts { + lockUntil := time.Now().Add(time.Duration(common.LockoutDuration) * time.Second) + t.LockedUntil = &lockUntil + } + + return t.Update() +} + +// IsLocked 检查账户是否被锁定 +func (t *TwoFA) IsLocked() bool { + if t.LockedUntil == nil { + return false + } + return time.Now().Before(*t.LockedUntil) +} + +// CreateBackupCodes 创建备用码 +func CreateBackupCodes(userId int, codes []string) error { + return DB.Transaction(func(tx *gorm.DB) error { + // 先删除现有的备用码 + if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil { + return err + } + + // 创建新的备用码记录 + for _, code := range codes { + hashedCode, err := common.HashBackupCode(code) + if err != nil { + return err + } + + backupCode := TwoFABackupCode{ + UserId: userId, + CodeHash: hashedCode, + IsUsed: false, + } + + if err := tx.Create(&backupCode).Error; err != nil { + return err + } + } + + return nil + }) +} + +// ValidateBackupCode 验证并使用备用码 +func ValidateBackupCode(userId int, code string) (bool, error) { + if !common.ValidateBackupCode(code) { + return false, errors.New("验证码或备用码不正确") + } + + normalizedCode := common.NormalizeBackupCode(code) + + // 查找未使用的备用码 + var backupCodes []TwoFABackupCode + if err := DB.Where("user_id = ? AND is_used = false", userId).Find(&backupCodes).Error; err != nil { + return false, err + } + + // 验证备用码 + for _, bc := range backupCodes { + if common.ValidatePasswordAndHash(normalizedCode, bc.CodeHash) { + // 标记为已使用 + now := time.Now() + bc.IsUsed = true + bc.UsedAt = &now + + if err := DB.Save(&bc).Error; err != nil { + return false, err + } + + return true, nil + } + } + + return false, nil +} + +// GetUnusedBackupCodeCount 获取未使用的备用码数量 +func GetUnusedBackupCodeCount(userId int) (int, error) { + var count int64 + err := DB.Model(&TwoFABackupCode{}).Where("user_id = ? AND is_used = false", userId).Count(&count).Error + return int(count), err +} + +// DisableTwoFA 禁用用户的2FA +func DisableTwoFA(userId int) error { + twoFA, err := GetTwoFAByUserId(userId) + if err != nil { + return err + } + if twoFA == nil { + return ErrTwoFANotEnabled + } + + // 删除2FA设置和备用码 + return twoFA.Delete() +} + +// EnableTwoFA 启用2FA +func (t *TwoFA) Enable() error { + t.IsEnabled = true + t.FailedAttempts = 0 + t.LockedUntil = nil + return t.Update() +} + +// ValidateTOTPAndUpdateUsage 验证TOTP并更新使用记录 +func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) { + // 检查是否被锁定 + if t.IsLocked() { + return false, fmt.Errorf("账户已被锁定,请在%v后重试", t.LockedUntil.Format("2006-01-02 15:04:05")) + } + + // 验证TOTP码 + if !common.ValidateTOTPCode(t.Secret, code) { + // 增加失败次数 + if err := t.IncrementFailedAttempts(); err != nil { + common.SysLog("更新2FA失败次数失败: " + err.Error()) + } + return false, nil + } + + // 验证成功,重置失败次数并更新最后使用时间 + now := time.Now() + t.FailedAttempts = 0 + t.LockedUntil = nil + t.LastUsedAt = &now + + if err := t.Update(); err != nil { + common.SysLog("更新2FA使用记录失败: " + err.Error()) + } + + return true, nil +} + +// ValidateBackupCodeAndUpdateUsage 验证备用码并更新使用记录 +func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) { + // 检查是否被锁定 + if t.IsLocked() { + return false, fmt.Errorf("账户已被锁定,请在%v后重试", t.LockedUntil.Format("2006-01-02 15:04:05")) + } + + // 验证备用码 + valid, err := ValidateBackupCode(t.UserId, code) + if err != nil { + return false, err + } + + if !valid { + // 增加失败次数 + if err := t.IncrementFailedAttempts(); err != nil { + common.SysLog("更新2FA失败次数失败: " + err.Error()) + } + return false, nil + } + + // 验证成功,重置失败次数并更新最后使用时间 + now := time.Now() + t.FailedAttempts = 0 + t.LockedUntil = nil + t.LastUsedAt = &now + + if err := t.Update(); err != nil { + common.SysLog("更新2FA使用记录失败: " + err.Error()) + } + + return true, nil +} + +// GetTwoFAStats 获取2FA统计信息(管理员使用) +func GetTwoFAStats() (map[string]interface{}, error) { + var totalUsers, enabledUsers int64 + + // 总用户数 + if err := DB.Model(&User{}).Count(&totalUsers).Error; err != nil { + return nil, err + } + + // 启用2FA的用户数 + if err := DB.Model(&TwoFA{}).Where("is_enabled = true").Count(&enabledUsers).Error; err != nil { + return nil, err + } + + enabledRate := float64(0) + if totalUsers > 0 { + enabledRate = float64(enabledUsers) / float64(totalUsers) * 100 + } + + return map[string]interface{}{ + "total_users": totalUsers, + "enabled_users": enabledUsers, + "enabled_rate": fmt.Sprintf("%.1f%%", enabledRate), + }, nil +} diff --git a/model/usedata.go b/model/usedata.go new file mode 100644 index 0000000000000000000000000000000000000000..f84beb8d904410e44768a4ea695af9ef92cfb195 --- /dev/null +++ b/model/usedata.go @@ -0,0 +1,128 @@ +package model + +import ( + "fmt" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "gorm.io/gorm" +) + +// QuotaData 柱状图数据 +type QuotaData struct { + Id int `json:"id"` + UserID int `json:"user_id" gorm:"index"` + Username string `json:"username" gorm:"index:idx_qdt_model_user_name,priority:2;size:64;default:''"` + ModelName string `json:"model_name" gorm:"index:idx_qdt_model_user_name,priority:1;size:64;default:''"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_qdt_created_at,priority:2"` + TokenUsed int `json:"token_used" gorm:"default:0"` + Count int `json:"count" gorm:"default:0"` + Quota int `json:"quota" gorm:"default:0"` +} + +func UpdateQuotaData() { + for { + if common.DataExportEnabled { + common.SysLog("正在更新数据看板数据...") + SaveQuotaDataCache() + } + time.Sleep(time.Duration(common.DataExportInterval) * time.Minute) + } +} + +var CacheQuotaData = make(map[string]*QuotaData) +var CacheQuotaDataLock = sync.Mutex{} + +func logQuotaDataCache(userId int, username string, modelName string, quota int, createdAt int64, tokenUsed int) { + key := fmt.Sprintf("%d-%s-%s-%d", userId, username, modelName, createdAt) + quotaData, ok := CacheQuotaData[key] + if ok { + quotaData.Count += 1 + quotaData.Quota += quota + quotaData.TokenUsed += tokenUsed + } else { + quotaData = &QuotaData{ + UserID: userId, + Username: username, + ModelName: modelName, + CreatedAt: createdAt, + Count: 1, + Quota: quota, + TokenUsed: tokenUsed, + } + } + CacheQuotaData[key] = quotaData +} + +func LogQuotaData(userId int, username string, modelName string, quota int, createdAt int64, tokenUsed int) { + // 只精确到小时 + createdAt = createdAt - (createdAt % 3600) + + CacheQuotaDataLock.Lock() + defer CacheQuotaDataLock.Unlock() + logQuotaDataCache(userId, username, modelName, quota, createdAt, tokenUsed) +} + +func SaveQuotaDataCache() { + CacheQuotaDataLock.Lock() + defer CacheQuotaDataLock.Unlock() + size := len(CacheQuotaData) + // 如果缓存中有数据,就保存到数据库中 + // 1. 先查询数据库中是否有数据 + // 2. 如果有数据,就更新数据 + // 3. 如果没有数据,就插入数据 + for _, quotaData := range CacheQuotaData { + quotaDataDB := &QuotaData{} + DB.Table("quota_data").Where("user_id = ? and username = ? and model_name = ? and created_at = ?", + quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt).First(quotaDataDB) + if quotaDataDB.Id > 0 { + //quotaDataDB.Count += quotaData.Count + //quotaDataDB.Quota += quotaData.Quota + //DB.Table("quota_data").Save(quotaDataDB) + increaseQuotaData(quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.Count, quotaData.Quota, quotaData.CreatedAt, quotaData.TokenUsed) + } else { + DB.Table("quota_data").Create(quotaData) + } + } + CacheQuotaData = make(map[string]*QuotaData) + common.SysLog(fmt.Sprintf("保存数据看板数据成功,共保存%d条数据", size)) +} + +func increaseQuotaData(userId int, username string, modelName string, count int, quota int, createdAt int64, tokenUsed int) { + err := DB.Table("quota_data").Where("user_id = ? and username = ? and model_name = ? and created_at = ?", + userId, username, modelName, createdAt).Updates(map[string]interface{}{ + "count": gorm.Expr("count + ?", count), + "quota": gorm.Expr("quota + ?", quota), + "token_used": gorm.Expr("token_used + ?", tokenUsed), + }).Error + if err != nil { + common.SysLog(fmt.Sprintf("increaseQuotaData error: %s", err)) + } +} + +func GetQuotaDataByUsername(username string, startTime int64, endTime int64) (quotaData []*QuotaData, err error) { + var quotaDatas []*QuotaData + // 从quota_data表中查询数据 + err = DB.Table("quota_data").Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime).Find("aDatas).Error + return quotaDatas, err +} + +func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData []*QuotaData, err error) { + var quotaDatas []*QuotaData + // 从quota_data表中查询数据 + err = DB.Table("quota_data").Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime).Find("aDatas).Error + return quotaDatas, err +} + +func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) { + if username != "" { + return GetQuotaDataByUsername(username, startTime, endTime) + } + var quotaDatas []*QuotaData + // 从quota_data表中查询数据 + // only select model_name, sum(count) as count, sum(quota) as quota, model_name, created_at from quota_data group by model_name, created_at; + //err = DB.Table("quota_data").Where("created_at >= ? and created_at <= ?", startTime, endTime).Find("aDatas).Error + err = DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime).Group("model_name, created_at").Find("aDatas).Error + return quotaDatas, err +} diff --git a/model/user.go b/model/user.go new file mode 100644 index 0000000000000000000000000000000000000000..395daa0b5da125a80e82e77e1b6ebac35cb3a736 --- /dev/null +++ b/model/user.go @@ -0,0 +1,931 @@ +package model + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + + "github.com/bytedance/gopkg/util/gopool" + "gorm.io/gorm" +) + +// User if you add sensitive fields, don't forget to clean them in setupLogin function. +// Otherwise, the sensitive information will be saved on local storage in plain text! +type User struct { + Id int `json:"id"` + Username string `json:"username" gorm:"unique;index" validate:"max=20"` + Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"` + OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database! + DisplayName string `json:"display_name" gorm:"index" validate:"max=20"` + Role int `json:"role" gorm:"type:int;default:1"` // admin, common + Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled + Email string `json:"email" gorm:"index" validate:"max=50"` + GitHubId string `json:"github_id" gorm:"column:github_id;index"` + DiscordId string `json:"discord_id" gorm:"column:discord_id;index"` + OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"` + WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"` + TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"` + VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! + AccessToken *string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management + Quota int `json:"quota" gorm:"type:int;default:0"` + UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota + RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number + Group string `json:"group" gorm:"type:varchar(64);default:'default'"` + AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"` + AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"` + AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度 + AffHistoryQuota int `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度 + InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"` + DeletedAt gorm.DeletedAt `gorm:"index"` + LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"` + Setting string `json:"setting" gorm:"type:text;column:setting"` + Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` + StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"` +} + +func (user *User) ToBaseUser() *UserBase { + cache := &UserBase{ + Id: user.Id, + Group: user.Group, + Quota: user.Quota, + Status: user.Status, + Username: user.Username, + Setting: user.Setting, + Email: user.Email, + } + return cache +} + +func (user *User) GetAccessToken() string { + if user.AccessToken == nil { + return "" + } + return *user.AccessToken +} + +func (user *User) SetAccessToken(token string) { + user.AccessToken = &token +} + +func (user *User) GetSetting() dto.UserSetting { + setting := dto.UserSetting{} + if user.Setting != "" { + err := json.Unmarshal([]byte(user.Setting), &setting) + if err != nil { + common.SysLog("failed to unmarshal setting: " + err.Error()) + } + } + return setting +} + +func (user *User) SetSetting(setting dto.UserSetting) { + settingBytes, err := json.Marshal(setting) + if err != nil { + common.SysLog("failed to marshal setting: " + err.Error()) + return + } + user.Setting = string(settingBytes) +} + +// 根据用户角色生成默认的边栏配置 +func generateDefaultSidebarConfigForRole(userRole int) string { + defaultConfig := map[string]interface{}{} + + // 聊天区域 - 所有用户都可以访问 + defaultConfig["chat"] = map[string]interface{}{ + "enabled": true, + "playground": true, + "chat": true, + } + + // 控制台区域 - 所有用户都可以访问 + defaultConfig["console"] = map[string]interface{}{ + "enabled": true, + "detail": true, + "token": true, + "log": true, + "midjourney": true, + "task": true, + } + + // 个人中心区域 - 所有用户都可以访问 + defaultConfig["personal"] = map[string]interface{}{ + "enabled": true, + "topup": true, + "personal": true, + } + + // 管理员区域 - 根据角色决定 + if userRole == common.RoleAdminUser { + // 管理员可以访问管理员区域,但不能访问系统设置 + defaultConfig["admin"] = map[string]interface{}{ + "enabled": true, + "channel": true, + "models": true, + "redemption": true, + "user": true, + "setting": false, // 管理员不能访问系统设置 + } + } else if userRole == common.RoleRootUser { + // 超级管理员可以访问所有功能 + defaultConfig["admin"] = map[string]interface{}{ + "enabled": true, + "channel": true, + "models": true, + "redemption": true, + "user": true, + "setting": true, + } + } + // 普通用户不包含admin区域 + + // 转换为JSON字符串 + configBytes, err := json.Marshal(defaultConfig) + if err != nil { + common.SysLog("生成默认边栏配置失败: " + err.Error()) + return "" + } + + return string(configBytes) +} + +// CheckUserExistOrDeleted check if user exist or deleted, if not exist, return false, nil, if deleted or exist, return true, nil +func CheckUserExistOrDeleted(username string, email string) (bool, error) { + var user User + + // err := DB.Unscoped().First(&user, "username = ? or email = ?", username, email).Error + // check email if empty + var err error + if email == "" { + err = DB.Unscoped().First(&user, "username = ?", username).Error + } else { + err = DB.Unscoped().First(&user, "username = ? or email = ?", username, email).Error + } + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // not exist, return false, nil + return false, nil + } + // other error, return false, err + return false, err + } + // exist, return true, nil + return true, nil +} + +func GetMaxUserId() int { + var user User + DB.Unscoped().Last(&user) + return user.Id +} + +func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err error) { + // Start transaction + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // Get total count within transaction + err = tx.Unscoped().Model(&User{}).Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // Get paginated users within same transaction + err = tx.Unscoped().Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("password").Find(&users).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // Commit transaction + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return users, total, nil +} + +func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, int64, error) { + var users []*User + var total int64 + var err error + + // 开始事务 + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 构建基础查询 + query := tx.Unscoped().Model(&User{}) + + // 构建搜索条件 + likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?" + + // 尝试将关键字转换为整数ID + keywordInt, err := strconv.Atoi(keyword) + if err == nil { + // 如果是数字,同时搜索ID和其他字段 + likeCondition = "id = ? OR " + likeCondition + if group != "" { + query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?", + keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group) + } else { + query = query.Where(likeCondition, + keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + } + } else { + // 非数字关键字,只搜索字符串字段 + if group != "" { + query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?", + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group) + } else { + query = query.Where(likeCondition, + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + } + } + + // 获取总数 + err = query.Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // 获取分页数据 + err = query.Omit("password").Order("id desc").Limit(num).Offset(startIdx).Find(&users).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // 提交事务 + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return users, total, nil +} + +func GetUserById(id int, selectAll bool) (*User, error) { + if id == 0 { + return nil, errors.New("id 为空!") + } + user := User{Id: id} + var err error = nil + if selectAll { + err = DB.First(&user, "id = ?", id).Error + } else { + err = DB.Omit("password").First(&user, "id = ?", id).Error + } + return &user, err +} + +func GetUserIdByAffCode(affCode string) (int, error) { + if affCode == "" { + return 0, errors.New("affCode 为空!") + } + var user User + err := DB.Select("id").First(&user, "aff_code = ?", affCode).Error + return user.Id, err +} + +func DeleteUserById(id int) (err error) { + if id == 0 { + return errors.New("id 为空!") + } + user := User{Id: id} + return user.Delete() +} + +func HardDeleteUserById(id int) error { + if id == 0 { + return errors.New("id 为空!") + } + err := DB.Unscoped().Delete(&User{}, "id = ?", id).Error + return err +} + +func inviteUser(inviterId int) (err error) { + user, err := GetUserById(inviterId, true) + if err != nil { + return err + } + user.AffCount++ + user.AffQuota += common.QuotaForInviter + user.AffHistoryQuota += common.QuotaForInviter + return DB.Save(user).Error +} + +func (user *User) TransferAffQuotaToQuota(quota int) error { + // 检查quota是否小于最小额度 + if float64(quota) < common.QuotaPerUnit { + return fmt.Errorf("转移额度最小为%s!", logger.LogQuota(int(common.QuotaPerUnit))) + } + + // 开始数据库事务 + tx := DB.Begin() + if tx.Error != nil { + return tx.Error + } + defer tx.Rollback() // 确保在函数退出时事务能回滚 + + // 加锁查询用户以确保数据一致性 + err := tx.Set("gorm:query_option", "FOR UPDATE").First(&user, user.Id).Error + if err != nil { + return err + } + + // 再次检查用户的AffQuota是否足够 + if user.AffQuota < quota { + return errors.New("邀请额度不足!") + } + + // 更新用户额度 + user.AffQuota -= quota + user.Quota += quota + + // 保存用户状态 + if err := tx.Save(user).Error; err != nil { + return err + } + + // 提交事务 + return tx.Commit().Error +} + +func (user *User) Insert(inviterId int) error { + var err error + if user.Password != "" { + user.Password, err = common.Password2Hash(user.Password) + if err != nil { + return err + } + } + user.Quota = common.QuotaForNewUser + //user.SetAccessToken(common.GetUUID()) + user.AffCode = common.GetRandomString(4) + + // 初始化用户设置,包括默认的边栏配置 + if user.Setting == "" { + defaultSetting := dto.UserSetting{} + // 这里暂时不设置SidebarModules,因为需要在用户创建后根据角色设置 + user.SetSetting(defaultSetting) + } + + result := DB.Create(user) + if result.Error != nil { + return result.Error + } + + // 用户创建成功后,根据角色初始化边栏配置 + // 需要重新获取用户以确保有正确的ID和Role + var createdUser User + if err := DB.Where("username = ?", user.Username).First(&createdUser).Error; err == nil { + // 生成基于角色的默认边栏配置 + defaultSidebarConfig := generateDefaultSidebarConfigForRole(createdUser.Role) + if defaultSidebarConfig != "" { + currentSetting := createdUser.GetSetting() + currentSetting.SidebarModules = defaultSidebarConfig + createdUser.SetSetting(currentSetting) + createdUser.Update(false) + common.SysLog(fmt.Sprintf("为新用户 %s (角色: %d) 初始化边栏配置", createdUser.Username, createdUser.Role)) + } + } + + if common.QuotaForNewUser > 0 { + RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser))) + } + if inviterId != 0 { + if common.QuotaForInvitee > 0 { + _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true) + RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee))) + } + if common.QuotaForInviter > 0 { + //_ = IncreaseUserQuota(inviterId, common.QuotaForInviter) + RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", logger.LogQuota(common.QuotaForInviter))) + _ = inviteUser(inviterId) + } + } + return nil +} + +func (user *User) Update(updatePassword bool) error { + var err error + if updatePassword { + user.Password, err = common.Password2Hash(user.Password) + if err != nil { + return err + } + } + newUser := *user + DB.First(&user, user.Id) + if err = DB.Model(user).Updates(newUser).Error; err != nil { + return err + } + + // Update cache + return updateUserCache(*user) +} + +func (user *User) Edit(updatePassword bool) error { + var err error + if updatePassword { + user.Password, err = common.Password2Hash(user.Password) + if err != nil { + return err + } + } + + newUser := *user + updates := map[string]interface{}{ + "username": newUser.Username, + "display_name": newUser.DisplayName, + "group": newUser.Group, + "quota": newUser.Quota, + "remark": newUser.Remark, + } + if updatePassword { + updates["password"] = newUser.Password + } + + DB.First(&user, user.Id) + if err = DB.Model(user).Updates(updates).Error; err != nil { + return err + } + + // Update cache + return updateUserCache(*user) +} + +func (user *User) Delete() error { + if user.Id == 0 { + return errors.New("id 为空!") + } + if err := DB.Delete(user).Error; err != nil { + return err + } + + // 清除缓存 + return invalidateUserCache(user.Id) +} + +func (user *User) HardDelete() error { + if user.Id == 0 { + return errors.New("id 为空!") + } + err := DB.Unscoped().Delete(user).Error + return err +} + +// ValidateAndFill check password & user status +func (user *User) ValidateAndFill() (err error) { + // When querying with struct, GORM will only query with non-zero fields, + // that means if your field's value is 0, '', false or other zero values, + // it won't be used to build query conditions + password := user.Password + username := strings.TrimSpace(user.Username) + if username == "" || password == "" { + return errors.New("用户名或密码为空") + } + // find buy username or email + DB.Where("username = ? OR email = ?", username, username).First(user) + okay := common.ValidatePasswordAndHash(password, user.Password) + if !okay || user.Status != common.UserStatusEnabled { + return errors.New("用户名或密码错误,或用户已被封禁") + } + return nil +} + +func (user *User) FillUserById() error { + if user.Id == 0 { + return errors.New("id 为空!") + } + DB.Where(User{Id: user.Id}).First(user) + return nil +} + +func (user *User) FillUserByEmail() error { + if user.Email == "" { + return errors.New("email 为空!") + } + DB.Where(User{Email: user.Email}).First(user) + return nil +} + +func (user *User) FillUserByGitHubId() error { + if user.GitHubId == "" { + return errors.New("GitHub id 为空!") + } + DB.Where(User{GitHubId: user.GitHubId}).First(user) + return nil +} + +func (user *User) FillUserByDiscordId() error { + if user.DiscordId == "" { + return errors.New("discord id 为空!") + } + DB.Where(User{DiscordId: user.DiscordId}).First(user) + return nil +} + +func (user *User) FillUserByOidcId() error { + if user.OidcId == "" { + return errors.New("oidc id 为空!") + } + DB.Where(User{OidcId: user.OidcId}).First(user) + return nil +} + +func (user *User) FillUserByWeChatId() error { + if user.WeChatId == "" { + return errors.New("WeChat id 为空!") + } + DB.Where(User{WeChatId: user.WeChatId}).First(user) + return nil +} + +func (user *User) FillUserByTelegramId() error { + if user.TelegramId == "" { + return errors.New("Telegram id 为空!") + } + err := DB.Where(User{TelegramId: user.TelegramId}).First(user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("该 Telegram 账户未绑定") + } + return nil +} + +func IsEmailAlreadyTaken(email string) bool { + return DB.Unscoped().Where("email = ?", email).Find(&User{}).RowsAffected == 1 +} + +func IsWeChatIdAlreadyTaken(wechatId string) bool { + return DB.Unscoped().Where("wechat_id = ?", wechatId).Find(&User{}).RowsAffected == 1 +} + +func IsGitHubIdAlreadyTaken(githubId string) bool { + return DB.Unscoped().Where("github_id = ?", githubId).Find(&User{}).RowsAffected == 1 +} + +func IsDiscordIdAlreadyTaken(discordId string) bool { + return DB.Unscoped().Where("discord_id = ?", discordId).Find(&User{}).RowsAffected == 1 +} + +func IsOidcIdAlreadyTaken(oidcId string) bool { + return DB.Where("oidc_id = ?", oidcId).Find(&User{}).RowsAffected == 1 +} + +func IsTelegramIdAlreadyTaken(telegramId string) bool { + return DB.Unscoped().Where("telegram_id = ?", telegramId).Find(&User{}).RowsAffected == 1 +} + +func ResetUserPasswordByEmail(email string, password string) error { + if email == "" || password == "" { + return errors.New("邮箱地址或密码为空!") + } + hashedPassword, err := common.Password2Hash(password) + if err != nil { + return err + } + err = DB.Model(&User{}).Where("email = ?", email).Update("password", hashedPassword).Error + return err +} + +func IsAdmin(userId int) bool { + if userId == 0 { + return false + } + var user User + err := DB.Where("id = ?", userId).Select("role").Find(&user).Error + if err != nil { + common.SysLog("no such user " + err.Error()) + return false + } + return user.Role >= common.RoleAdminUser +} + +//// IsUserEnabled checks user status from Redis first, falls back to DB if needed +//func IsUserEnabled(id int, fromDB bool) (status bool, err error) { +// defer func() { +// // Update Redis cache asynchronously on successful DB read +// if shouldUpdateRedis(fromDB, err) { +// gopool.Go(func() { +// if err := updateUserStatusCache(id, status); err != nil { +// common.SysError("failed to update user status cache: " + err.Error()) +// } +// }) +// } +// }() +// if !fromDB && common.RedisEnabled { +// // Try Redis first +// status, err := getUserStatusCache(id) +// if err == nil { +// return status == common.UserStatusEnabled, nil +// } +// // Don't return error - fall through to DB +// } +// fromDB = true +// var user User +// err = DB.Where("id = ?", id).Select("status").Find(&user).Error +// if err != nil { +// return false, err +// } +// +// return user.Status == common.UserStatusEnabled, nil +//} + +func ValidateAccessToken(token string) (user *User) { + if token == "" { + return nil + } + token = strings.Replace(token, "Bearer ", "", 1) + user = &User{} + if DB.Where("access_token = ?", token).First(user).RowsAffected == 1 { + return user + } + return nil +} + +// GetUserQuota gets quota from Redis first, falls back to DB if needed +func GetUserQuota(id int, fromDB bool) (quota int, err error) { + defer func() { + // Update Redis cache asynchronously on successful DB read + if shouldUpdateRedis(fromDB, err) { + gopool.Go(func() { + if err := updateUserQuotaCache(id, quota); err != nil { + common.SysLog("failed to update user quota cache: " + err.Error()) + } + }) + } + }() + if !fromDB && common.RedisEnabled { + quota, err := getUserQuotaCache(id) + if err == nil { + return quota, nil + } + // Don't return error - fall through to DB + } + fromDB = true + err = DB.Model(&User{}).Where("id = ?", id).Select("quota").Find("a).Error + if err != nil { + return 0, err + } + + return quota, nil +} + +func GetUserUsedQuota(id int) (quota int, err error) { + err = DB.Model(&User{}).Where("id = ?", id).Select("used_quota").Find("a).Error + return quota, err +} + +func GetUserEmail(id int) (email string, err error) { + err = DB.Model(&User{}).Where("id = ?", id).Select("email").Find(&email).Error + return email, err +} + +// GetUserGroup gets group from Redis first, falls back to DB if needed +func GetUserGroup(id int, fromDB bool) (group string, err error) { + defer func() { + // Update Redis cache asynchronously on successful DB read + if shouldUpdateRedis(fromDB, err) { + gopool.Go(func() { + if err := updateUserGroupCache(id, group); err != nil { + common.SysLog("failed to update user group cache: " + err.Error()) + } + }) + } + }() + if !fromDB && common.RedisEnabled { + group, err := getUserGroupCache(id) + if err == nil { + return group, nil + } + // Don't return error - fall through to DB + } + fromDB = true + err = DB.Model(&User{}).Where("id = ?", id).Select(commonGroupCol).Find(&group).Error + if err != nil { + return "", err + } + + return group, nil +} + +// GetUserSetting gets setting from Redis first, falls back to DB if needed +func GetUserSetting(id int, fromDB bool) (settingMap dto.UserSetting, err error) { + var setting string + defer func() { + // Update Redis cache asynchronously on successful DB read + if shouldUpdateRedis(fromDB, err) { + gopool.Go(func() { + if err := updateUserSettingCache(id, setting); err != nil { + common.SysLog("failed to update user setting cache: " + err.Error()) + } + }) + } + }() + if !fromDB && common.RedisEnabled { + setting, err := getUserSettingCache(id) + if err == nil { + return setting, nil + } + // Don't return error - fall through to DB + } + fromDB = true + err = DB.Model(&User{}).Where("id = ?", id).Select("setting").Find(&setting).Error + if err != nil { + return settingMap, err + } + userBase := &UserBase{ + Setting: setting, + } + return userBase.GetSetting(), nil +} + +func IncreaseUserQuota(id int, quota int, db bool) (err error) { + if quota < 0 { + return errors.New("quota 不能为负数!") + } + gopool.Go(func() { + err := cacheIncrUserQuota(id, int64(quota)) + if err != nil { + common.SysLog("failed to increase user quota: " + err.Error()) + } + }) + if !db && common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeUserQuota, id, quota) + return nil + } + return increaseUserQuota(id, quota) +} + +func increaseUserQuota(id int, quota int) (err error) { + err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota + ?", quota)).Error + if err != nil { + return err + } + return err +} + +func DecreaseUserQuota(id int, quota int) (err error) { + if quota < 0 { + return errors.New("quota 不能为负数!") + } + gopool.Go(func() { + err := cacheDecrUserQuota(id, int64(quota)) + if err != nil { + common.SysLog("failed to decrease user quota: " + err.Error()) + } + }) + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeUserQuota, id, -quota) + return nil + } + return decreaseUserQuota(id, quota) +} + +func decreaseUserQuota(id int, quota int) (err error) { + err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota - ?", quota)).Error + if err != nil { + return err + } + return err +} + +func DeltaUpdateUserQuota(id int, delta int) (err error) { + if delta == 0 { + return nil + } + if delta > 0 { + return IncreaseUserQuota(id, delta, false) + } else { + return DecreaseUserQuota(id, -delta) + } +} + +//func GetRootUserEmail() (email string) { +// DB.Model(&User{}).Where("role = ?", common.RoleRootUser).Select("email").Find(&email) +// return email +//} + +func GetRootUser() (user *User) { + DB.Where("role = ?", common.RoleRootUser).First(&user) + return user +} + +func UpdateUserUsedQuotaAndRequestCount(id int, quota int) { + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeUsedQuota, id, quota) + addNewRecord(BatchUpdateTypeRequestCount, id, 1) + return + } + updateUserUsedQuotaAndRequestCount(id, quota, 1) +} + +func updateUserUsedQuotaAndRequestCount(id int, quota int, count int) { + err := DB.Model(&User{}).Where("id = ?", id).Updates( + map[string]interface{}{ + "used_quota": gorm.Expr("used_quota + ?", quota), + "request_count": gorm.Expr("request_count + ?", count), + }, + ).Error + if err != nil { + common.SysLog("failed to update user used quota and request count: " + err.Error()) + return + } + + //// 更新缓存 + //if err := invalidateUserCache(id); err != nil { + // common.SysError("failed to invalidate user cache: " + err.Error()) + //} +} + +func updateUserUsedQuota(id int, quota int) { + err := DB.Model(&User{}).Where("id = ?", id).Updates( + map[string]interface{}{ + "used_quota": gorm.Expr("used_quota + ?", quota), + }, + ).Error + if err != nil { + common.SysLog("failed to update user used quota: " + err.Error()) + } +} + +func updateUserRequestCount(id int, count int) { + err := DB.Model(&User{}).Where("id = ?", id).Update("request_count", gorm.Expr("request_count + ?", count)).Error + if err != nil { + common.SysLog("failed to update user request count: " + err.Error()) + } +} + +// GetUsernameById gets username from Redis first, falls back to DB if needed +func GetUsernameById(id int, fromDB bool) (username string, err error) { + defer func() { + // Update Redis cache asynchronously on successful DB read + if shouldUpdateRedis(fromDB, err) { + gopool.Go(func() { + if err := updateUserNameCache(id, username); err != nil { + common.SysLog("failed to update user name cache: " + err.Error()) + } + }) + } + }() + if !fromDB && common.RedisEnabled { + username, err := getUserNameCache(id) + if err == nil { + return username, nil + } + // Don't return error - fall through to DB + } + fromDB = true + err = DB.Model(&User{}).Where("id = ?", id).Select("username").Find(&username).Error + if err != nil { + return "", err + } + + return username, nil +} + +func IsLinuxDOIdAlreadyTaken(linuxDOId string) bool { + var user User + err := DB.Unscoped().Where("linux_do_id = ?", linuxDOId).First(&user).Error + return !errors.Is(err, gorm.ErrRecordNotFound) +} + +func (user *User) FillUserByLinuxDOId() error { + if user.LinuxDOId == "" { + return errors.New("linux do id is empty") + } + err := DB.Where("linux_do_id = ?", user.LinuxDOId).First(user).Error + return err +} + +func RootUserExists() bool { + var user User + err := DB.Where("role = ?", common.RoleRootUser).First(&user).Error + if err != nil { + return false + } + return true +} diff --git a/model/user_cache.go b/model/user_cache.go new file mode 100644 index 0000000000000000000000000000000000000000..d06acd80eee87fac9b3e5435461e7a1a9ef40f31 --- /dev/null +++ b/model/user_cache.go @@ -0,0 +1,219 @@ +package model + +import ( + "fmt" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + + "github.com/gin-gonic/gin" + + "github.com/bytedance/gopkg/util/gopool" +) + +// UserBase struct remains the same as it represents the cached data structure +type UserBase struct { + Id int `json:"id"` + Group string `json:"group"` + Email string `json:"email"` + Quota int `json:"quota"` + Status int `json:"status"` + Username string `json:"username"` + Setting string `json:"setting"` +} + +func (user *UserBase) WriteContext(c *gin.Context) { + common.SetContextKey(c, constant.ContextKeyUserGroup, user.Group) + common.SetContextKey(c, constant.ContextKeyUserQuota, user.Quota) + common.SetContextKey(c, constant.ContextKeyUserStatus, user.Status) + common.SetContextKey(c, constant.ContextKeyUserEmail, user.Email) + common.SetContextKey(c, constant.ContextKeyUserName, user.Username) + common.SetContextKey(c, constant.ContextKeyUserSetting, user.GetSetting()) +} + +func (user *UserBase) GetSetting() dto.UserSetting { + setting := dto.UserSetting{} + if user.Setting != "" { + err := common.Unmarshal([]byte(user.Setting), &setting) + if err != nil { + common.SysLog("failed to unmarshal setting: " + err.Error()) + } + } + return setting +} + +// getUserCacheKey returns the key for user cache +func getUserCacheKey(userId int) string { + return fmt.Sprintf("user:%d", userId) +} + +// invalidateUserCache clears user cache +func invalidateUserCache(userId int) error { + if !common.RedisEnabled { + return nil + } + return common.RedisDelKey(getUserCacheKey(userId)) +} + +// updateUserCache updates all user cache fields using hash +func updateUserCache(user User) error { + if !common.RedisEnabled { + return nil + } + + return common.RedisHSetObj( + getUserCacheKey(user.Id), + user.ToBaseUser(), + time.Duration(common.RedisKeyCacheSeconds())*time.Second, + ) +} + +// GetUserCache gets complete user cache from hash +func GetUserCache(userId int) (userCache *UserBase, err error) { + var user *User + var fromDB bool + defer func() { + // Update Redis cache asynchronously on successful DB read + if shouldUpdateRedis(fromDB, err) && user != nil { + gopool.Go(func() { + if err := updateUserCache(*user); err != nil { + common.SysLog("failed to update user status cache: " + err.Error()) + } + }) + } + }() + + // Try getting from Redis first + userCache, err = cacheGetUserBase(userId) + if err == nil { + return userCache, nil + } + + // If Redis fails, get from DB + fromDB = true + user, err = GetUserById(userId, false) + if err != nil { + return nil, err // Return nil and error if DB lookup fails + } + + // Create cache object from user data + userCache = &UserBase{ + Id: user.Id, + Group: user.Group, + Quota: user.Quota, + Status: user.Status, + Username: user.Username, + Setting: user.Setting, + Email: user.Email, + } + + return userCache, nil +} + +func cacheGetUserBase(userId int) (*UserBase, error) { + if !common.RedisEnabled { + return nil, fmt.Errorf("redis is not enabled") + } + var userCache UserBase + // Try getting from Redis first + err := common.RedisHGetObj(getUserCacheKey(userId), &userCache) + if err != nil { + return nil, err + } + return &userCache, nil +} + +// Add atomic quota operations using hash fields +func cacheIncrUserQuota(userId int, delta int64) error { + if !common.RedisEnabled { + return nil + } + return common.RedisHIncrBy(getUserCacheKey(userId), "Quota", delta) +} + +func cacheDecrUserQuota(userId int, delta int64) error { + return cacheIncrUserQuota(userId, -delta) +} + +// Helper functions to get individual fields if needed +func getUserGroupCache(userId int) (string, error) { + cache, err := GetUserCache(userId) + if err != nil { + return "", err + } + return cache.Group, nil +} + +func getUserQuotaCache(userId int) (int, error) { + cache, err := GetUserCache(userId) + if err != nil { + return 0, err + } + return cache.Quota, nil +} + +func getUserStatusCache(userId int) (int, error) { + cache, err := GetUserCache(userId) + if err != nil { + return 0, err + } + return cache.Status, nil +} + +func getUserNameCache(userId int) (string, error) { + cache, err := GetUserCache(userId) + if err != nil { + return "", err + } + return cache.Username, nil +} + +func getUserSettingCache(userId int) (dto.UserSetting, error) { + cache, err := GetUserCache(userId) + if err != nil { + return dto.UserSetting{}, err + } + return cache.GetSetting(), nil +} + +// New functions for individual field updates +func updateUserStatusCache(userId int, status bool) error { + if !common.RedisEnabled { + return nil + } + statusInt := common.UserStatusEnabled + if !status { + statusInt = common.UserStatusDisabled + } + return common.RedisHSetField(getUserCacheKey(userId), "Status", fmt.Sprintf("%d", statusInt)) +} + +func updateUserQuotaCache(userId int, quota int) error { + if !common.RedisEnabled { + return nil + } + return common.RedisHSetField(getUserCacheKey(userId), "Quota", fmt.Sprintf("%d", quota)) +} + +func updateUserGroupCache(userId int, group string) error { + if !common.RedisEnabled { + return nil + } + return common.RedisHSetField(getUserCacheKey(userId), "Group", group) +} + +func updateUserNameCache(userId int, username string) error { + if !common.RedisEnabled { + return nil + } + return common.RedisHSetField(getUserCacheKey(userId), "Username", username) +} + +func updateUserSettingCache(userId int, setting string) error { + if !common.RedisEnabled { + return nil + } + return common.RedisHSetField(getUserCacheKey(userId), "Setting", setting) +} diff --git a/model/utils.go b/model/utils.go new file mode 100644 index 0000000000000000000000000000000000000000..adfd8e139a0582c34788fec2f8e45fe09498d628 --- /dev/null +++ b/model/utils.go @@ -0,0 +1,112 @@ +package model + +import ( + "errors" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + + "github.com/bytedance/gopkg/util/gopool" + "gorm.io/gorm" +) + +const ( + BatchUpdateTypeUserQuota = iota + BatchUpdateTypeTokenQuota + BatchUpdateTypeUsedQuota + BatchUpdateTypeChannelUsedQuota + BatchUpdateTypeRequestCount + BatchUpdateTypeCount // if you add a new type, you need to add a new map and a new lock +) + +var batchUpdateStores []map[int]int +var batchUpdateLocks []sync.Mutex + +func init() { + for i := 0; i < BatchUpdateTypeCount; i++ { + batchUpdateStores = append(batchUpdateStores, make(map[int]int)) + batchUpdateLocks = append(batchUpdateLocks, sync.Mutex{}) + } +} + +func InitBatchUpdater() { + gopool.Go(func() { + for { + time.Sleep(time.Duration(common.BatchUpdateInterval) * time.Second) + batchUpdate() + } + }) +} + +func addNewRecord(type_ int, id int, value int) { + batchUpdateLocks[type_].Lock() + defer batchUpdateLocks[type_].Unlock() + if _, ok := batchUpdateStores[type_][id]; !ok { + batchUpdateStores[type_][id] = value + } else { + batchUpdateStores[type_][id] += value + } +} + +func batchUpdate() { + // check if there's any data to update + hasData := false + for i := 0; i < BatchUpdateTypeCount; i++ { + batchUpdateLocks[i].Lock() + if len(batchUpdateStores[i]) > 0 { + hasData = true + batchUpdateLocks[i].Unlock() + break + } + batchUpdateLocks[i].Unlock() + } + + if !hasData { + return + } + + common.SysLog("batch update started") + for i := 0; i < BatchUpdateTypeCount; i++ { + batchUpdateLocks[i].Lock() + store := batchUpdateStores[i] + batchUpdateStores[i] = make(map[int]int) + batchUpdateLocks[i].Unlock() + // TODO: maybe we can combine updates with same key? + for key, value := range store { + switch i { + case BatchUpdateTypeUserQuota: + err := increaseUserQuota(key, value) + if err != nil { + common.SysLog("failed to batch update user quota: " + err.Error()) + } + case BatchUpdateTypeTokenQuota: + err := increaseTokenQuota(key, value) + if err != nil { + common.SysLog("failed to batch update token quota: " + err.Error()) + } + case BatchUpdateTypeUsedQuota: + updateUserUsedQuota(key, value) + case BatchUpdateTypeRequestCount: + updateUserRequestCount(key, value) + case BatchUpdateTypeChannelUsedQuota: + updateChannelUsedQuota(key, value) + } + } + } + common.SysLog("batch update finished") +} + +func RecordExist(err error) (bool, error) { + if err == nil { + return true, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err +} + +func shouldUpdateRedis(fromDB bool, err error) bool { + return common.RedisEnabled && fromDB && err == nil +} diff --git a/model/vendor_meta.go b/model/vendor_meta.go new file mode 100644 index 0000000000000000000000000000000000000000..2bb357f82a1fbdb4e2bed24e75ef0b4a1c9018c1 --- /dev/null +++ b/model/vendor_meta.go @@ -0,0 +1,88 @@ +package model + +import ( + "github.com/QuantumNous/new-api/common" + + "gorm.io/gorm" +) + +// Vendor 用于存储供应商信息,供模型引用 +// Name 唯一,用于在模型中关联 +// Icon 采用 @lobehub/icons 的图标名,前端可直接渲染 +// Status 预留字段,1 表示启用 +// 本表同样遵循 3NF 设计范式 + +type Vendor struct { + Id int `json:"id"` + Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name_delete_at,priority:1"` + Description string `json:"description,omitempty" gorm:"type:text"` + Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` + Status int `json:"status" gorm:"default:1"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name_delete_at,priority:2"` +} + +// Insert 创建新的供应商记录 +func (v *Vendor) Insert() error { + now := common.GetTimestamp() + v.CreatedTime = now + v.UpdatedTime = now + return DB.Create(v).Error +} + +// IsVendorNameDuplicated 检查供应商名称是否重复(排除自身 ID) +func IsVendorNameDuplicated(id int, name string) (bool, error) { + if name == "" { + return false, nil + } + var cnt int64 + err := DB.Model(&Vendor{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error + return cnt > 0, err +} + +// Update 更新供应商记录 +func (v *Vendor) Update() error { + v.UpdatedTime = common.GetTimestamp() + return DB.Save(v).Error +} + +// Delete 软删除供应商 +func (v *Vendor) Delete() error { + return DB.Delete(v).Error +} + +// GetVendorByID 根据 ID 获取供应商 +func GetVendorByID(id int) (*Vendor, error) { + var v Vendor + err := DB.First(&v, id).Error + if err != nil { + return nil, err + } + return &v, nil +} + +// GetAllVendors 获取全部供应商(分页) +func GetAllVendors(offset int, limit int) ([]*Vendor, error) { + var vendors []*Vendor + err := DB.Offset(offset).Limit(limit).Find(&vendors).Error + return vendors, err +} + +// SearchVendors 按关键字搜索供应商 +func SearchVendors(keyword string, offset int, limit int) ([]*Vendor, int64, error) { + db := DB.Model(&Vendor{}) + if keyword != "" { + like := "%" + keyword + "%" + db = db.Where("name LIKE ? OR description LIKE ?", like, like) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var vendors []*Vendor + if err := db.Offset(offset).Limit(limit).Order("id DESC").Find(&vendors).Error; err != nil { + return nil, 0, err + } + return vendors, total, nil +} diff --git a/new-api.service b/new-api.service new file mode 100644 index 0000000000000000000000000000000000000000..5a29336153f48b40ccf7f78d8b6c7cd760b78853 --- /dev/null +++ b/new-api.service @@ -0,0 +1,18 @@ +# File path: /etc/systemd/system/new-api.service +# sudo systemctl daemon-reload +# sudo systemctl start new-api +# sudo systemctl enable new-api +# sudo systemctl status new-api +[Unit] +Description=One API Service +After=network.target + +[Service] +User=ubuntu # 注意修改用户名 +WorkingDirectory=/path/to/new-api # 注意修改路径 +ExecStart=/path/to/new-api/new-api --port 3000 --log-dir /path/to/new-api/logs # 注意修改路径和端口号 +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/relay/audio_handler.go b/relay/audio_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..15fbb9390a380aaf6fdd03413efdf3ac2ee58ba4 --- /dev/null +++ b/relay/audio_handler.go @@ -0,0 +1,74 @@ +package relay + +import ( + "errors" + "fmt" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + audioReq, ok := info.Request.(*dto.AudioRequest) + if !ok { + return types.NewError(errors.New("invalid request type"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(audioReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to AudioRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + ioReader, err := adaptor.ConvertAudioRequest(c, info, *request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + resp, err := adaptor.DoRequest(c, info, ioReader) + if err != nil { + return types.NewError(err, types.ErrorCodeDoRequestFailed) + } + statusCodeMappingStr := c.GetString("status_code_mapping") + + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + + postConsumeQuota(c, info, usage.(*dto.Usage), "") + + return nil +} diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..7f8faf22d8dfa75fe9f6c0808f13c8efa40c0949 --- /dev/null +++ b/relay/channel/adapter.go @@ -0,0 +1,57 @@ +package channel + +import ( + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor interface { + // Init IsStream bool + Init(info *relaycommon.RelayInfo) + GetRequestURL(info *relaycommon.RelayInfo) (string, error) + SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error + ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) + ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) + ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) + ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) + ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) + ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) + DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) + DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) + GetModelList() []string + GetChannelName() string + ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) + ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) +} + +type TaskAdaptor interface { + Init(info *relaycommon.RelayInfo) + + ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError + + BuildRequestURL(info *relaycommon.RelayInfo) (string, error) + BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error + BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) + + DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) + DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, err *dto.TaskError) + + GetModelList() []string + GetChannelName() string + + // FetchTask + FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) + + ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) +} + +type OpenAIVideoConverter interface { + ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) +} diff --git a/relay/channel/ai360/constants.go b/relay/channel/ai360/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..4b09dd563e8836d3124ba24da16ecbb9cb5bea41 --- /dev/null +++ b/relay/channel/ai360/constants.go @@ -0,0 +1,14 @@ +package ai360 + +var ModelList = []string{ + "360gpt-turbo", + "360gpt-turbo-responsibility-8k", + "360gpt-pro", + "360gpt2-pro", + "360GPT_S2_V9", + "embedding-bert-512-v1", + "embedding_s1_v1", + "semantic_similarity_s1_v1", +} + +var ChannelName = "ai360" diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..adce01822033bb2bff0056ce4d78203f39a3863f --- /dev/null +++ b/relay/channel/ali/adaptor.go @@ -0,0 +1,195 @@ +package ali + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/claude" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + return req, nil +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + var fullRequestURL string + switch info.RelayFormat { + case types.RelayFormatClaude: + fullRequestURL = fmt.Sprintf("%s/api/v2/apps/claude-code-proxy/v1/messages", info.ChannelBaseUrl) + default: + switch info.RelayMode { + case constant.RelayModeEmbeddings: + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", info.ChannelBaseUrl) + case constant.RelayModeRerank: + fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl) + case constant.RelayModeImagesGenerations: + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl) + case constant.RelayModeImagesEdits: + if isWanModel(info.OriginModelName) { + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image2image/image-synthesis", info.ChannelBaseUrl) + } else { + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl) + } + case constant.RelayModeCompletions: + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl) + default: + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.ChannelBaseUrl) + } + } + + return fullRequestURL, nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + if info.IsStream { + req.Set("X-DashScope-SSE", "enable") + } + if c.GetString("plugin") != "" { + req.Set("X-DashScope-Plugin", c.GetString("plugin")) + } + if info.RelayMode == constant.RelayModeImagesGenerations { + req.Set("X-DashScope-Async", "enable") + } + if info.RelayMode == constant.RelayModeImagesEdits { + if isWanModel(info.OriginModelName) { + req.Set("X-DashScope-Async", "enable") + } + req.Set("Content-Type", "application/json") + } + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + // docs: https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2712216 + // fix: InternalError.Algo.InvalidParameter: The value of the enable_thinking parameter is restricted to True. + //if strings.Contains(request.Model, "thinking") { + // request.EnableThinking = true + // request.Stream = true + // info.IsStream = true + //} + //// fix: ali parameter.enable_thinking must be set to false for non-streaming calls + //if !info.IsStream { + // request.EnableThinking = false + //} + + switch info.RelayMode { + default: + aliReq := requestOpenAI2Ali(*request) + return aliReq, nil + } +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + if info.RelayMode == constant.RelayModeImagesGenerations { + aliRequest, err := oaiImage2Ali(request) + if err != nil { + return nil, fmt.Errorf("convert image request failed: %w", err) + } + return aliRequest, nil + } else if info.RelayMode == constant.RelayModeImagesEdits { + if isWanModel(info.OriginModelName) { + return oaiFormEdit2WanxImageEdit(c, info, request) + } + // ali image edit https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2976416 + // 如果用户使用表单,则需要解析表单数据 + if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { + aliRequest, err := oaiFormEdit2AliImageEdit(c, info, request) + if err != nil { + return nil, fmt.Errorf("convert image edit form request failed: %w", err) + } + return aliRequest, nil + } else { + aliRequest, err := oaiImage2Ali(request) + if err != nil { + return nil, fmt.Errorf("convert image request failed: %w", err) + } + return aliRequest, nil + } + } + return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode) +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return ConvertRerankRequest(request), nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayFormat { + case types.RelayFormatClaude: + if info.IsStream { + return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + } else { + return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) + } + default: + switch info.RelayMode { + case constant.RelayModeImagesGenerations: + err, usage = aliImageHandler(c, resp, info) + case constant.RelayModeImagesEdits: + if isWanModel(info.OriginModelName) { + err, usage = aliImageHandler(c, resp, info) + } else { + err, usage = aliImageEditHandler(c, resp, info) + } + case constant.RelayModeRerank: + err, usage = RerankHandler(c, resp, info) + default: + adaptor := openai.Adaptor{} + usage, err = adaptor.DoResponse(c, resp, info) + } + return usage, err + } +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/ali/constants.go b/relay/channel/ali/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..df64439bc17426ec25e95ef38902a03fdaaa552f --- /dev/null +++ b/relay/channel/ali/constants.go @@ -0,0 +1,14 @@ +package ali + +var ModelList = []string{ + "qwen-turbo", + "qwen-plus", + "qwen-max", + "qwen-max-longcontext", + "qwq-32b", + "qwen3-235b-a22b", + "text-embedding-v1", + "gte-rerank-v2", +} + +var ChannelName = "ali" diff --git a/relay/channel/ali/dto.go b/relay/channel/ali/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..26f14a6c03aa98042312275cdfcd761ec0450e1b --- /dev/null +++ b/relay/channel/ali/dto.go @@ -0,0 +1,151 @@ +package ali + +import "github.com/QuantumNous/new-api/dto" + +type AliMessage struct { + Content any `json:"content"` + Role string `json:"role"` +} + +type AliMediaContent struct { + Image string `json:"image,omitempty"` + Text string `json:"text,omitempty"` +} + +type AliInput struct { + Prompt string `json:"prompt,omitempty"` + //History []AliMessage `json:"history,omitempty"` + Messages []AliMessage `json:"messages"` +} + +type AliParameters struct { + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + Seed uint64 `json:"seed,omitempty"` + EnableSearch bool `json:"enable_search,omitempty"` + IncrementalOutput bool `json:"incremental_output,omitempty"` +} + +type AliChatRequest struct { + Model string `json:"model"` + Input AliInput `json:"input,omitempty"` + Parameters AliParameters `json:"parameters,omitempty"` +} + +type AliEmbeddingRequest struct { + Model string `json:"model"` + Input struct { + Texts []string `json:"texts"` + } `json:"input"` + Parameters *struct { + TextType string `json:"text_type,omitempty"` + } `json:"parameters,omitempty"` +} + +type AliEmbedding struct { + Embedding []float64 `json:"embedding"` + TextIndex int `json:"text_index"` +} + +type AliEmbeddingResponse struct { + Output struct { + Embeddings []AliEmbedding `json:"embeddings"` + } `json:"output"` + Usage AliUsage `json:"usage"` + AliError +} + +type AliError struct { + Code string `json:"code"` + Message string `json:"message"` + RequestId string `json:"request_id"` +} + +type AliUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type TaskResult struct { + B64Image string `json:"b64_image,omitempty"` + Url string `json:"url,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +type AliOutput struct { + TaskId string `json:"task_id,omitempty"` + TaskStatus string `json:"task_status,omitempty"` + Text string `json:"text"` + FinishReason string `json:"finish_reason"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + Results []TaskResult `json:"results,omitempty"` + Choices []map[string]any `json:"choices,omitempty"` +} + +type AliResponse struct { + Output AliOutput `json:"output"` + Usage AliUsage `json:"usage"` + AliError +} + +type AliImageRequest struct { + Model string `json:"model"` + Input any `json:"input"` + Parameters any `json:"parameters,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` +} + +type AliImageParameters struct { + Size string `json:"size,omitempty"` + N int `json:"n,omitempty"` + Steps string `json:"steps,omitempty"` + Scale string `json:"scale,omitempty"` + Watermark *bool `json:"watermark,omitempty"` +} + +type AliImageInput struct { + Prompt string `json:"prompt,omitempty"` + NegativePrompt string `json:"negative_prompt,omitempty"` + Messages []AliMessage `json:"messages,omitempty"` +} + +type WanImageInput struct { + Prompt string `json:"prompt"` // 必需:文本提示词,描述生成图像中期望包含的元素和视觉特点 + Images []string `json:"images"` // 必需:图像URL数组,长度不超过2,支持HTTP/HTTPS URL或Base64编码 + NegativePrompt string `json:"negative_prompt,omitempty"` // 可选:反向提示词,描述不希望在画面中看到的内容 +} + +type WanImageParameters struct { + N int `json:"n,omitempty"` // 生成图片数量,取值范围1-4,默认4 + Watermark *bool `json:"watermark,omitempty"` // 是否添加水印标识,默认false + Seed int `json:"seed,omitempty"` // 随机数种子,取值范围[0, 2147483647] + Strength float64 `json:"strength,omitempty"` // 修改幅度 0.0-1.0,默认0.5(部分模型支持) +} + +type AliRerankParameters struct { + TopN *int `json:"top_n,omitempty"` + ReturnDocuments *bool `json:"return_documents,omitempty"` +} + +type AliRerankInput struct { + Query string `json:"query"` + Documents []any `json:"documents"` +} + +type AliRerankRequest struct { + Model string `json:"model"` + Input AliRerankInput `json:"input"` + Parameters AliRerankParameters `json:"parameters,omitempty"` +} + +type AliRerankResponse struct { + Output struct { + Results []dto.RerankResponseResult `json:"results"` + } `json:"output"` + Usage AliUsage `json:"usage"` + RequestId string `json:"request_id"` + AliError +} diff --git a/relay/channel/ali/image.go b/relay/channel/ali/image.go new file mode 100644 index 0000000000000000000000000000000000000000..0e3fe1ea0c83a46b755079a2f29867270e4f77fd --- /dev/null +++ b/relay/channel/ali/image.go @@ -0,0 +1,347 @@ +package ali + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) { + var imageRequest AliImageRequest + imageRequest.Model = request.Model + imageRequest.ResponseFormat = request.ResponseFormat + logger.LogJson(context.Background(), "oaiImage2Ali request extra", request.Extra) + if request.Extra != nil { + if val, ok := request.Extra["parameters"]; ok { + err := common.Unmarshal(val, &imageRequest.Parameters) + if err != nil { + return nil, fmt.Errorf("invalid parameters field: %w", err) + } + } + if val, ok := request.Extra["input"]; ok { + err := common.Unmarshal(val, &imageRequest.Input) + if err != nil { + return nil, fmt.Errorf("invalid input field: %w", err) + } + } + } + + if imageRequest.Parameters == nil { + imageRequest.Parameters = AliImageParameters{ + Size: strings.Replace(request.Size, "x", "*", -1), + N: int(request.N), + Watermark: request.Watermark, + } + } + + if imageRequest.Input == nil { + imageRequest.Input = AliImageInput{ + Prompt: request.Prompt, + } + } + + return &imageRequest, nil +} + +func getImageBase64sFromForm(c *gin.Context, fieldName string) ([]string, error) { + mf := c.Request.MultipartForm + if mf == nil { + if _, err := c.MultipartForm(); err != nil { + return nil, fmt.Errorf("failed to parse image edit form request: %w", err) + } + mf = c.Request.MultipartForm + } + + var imageFiles []*multipart.FileHeader + var exists bool + + // First check for standard "image" field + if imageFiles, exists = mf.File["image"]; !exists || len(imageFiles) == 0 { + // If not found, check for "image[]" field + if imageFiles, exists = mf.File["image[]"]; !exists || len(imageFiles) == 0 { + // If still not found, iterate through all fields to find any that start with "image[" + foundArrayImages := false + for fieldName, files := range mf.File { + if strings.HasPrefix(fieldName, "image[") && len(files) > 0 { + foundArrayImages = true + imageFiles = append(imageFiles, files...) + } + } + + // If no image fields found at all + if !foundArrayImages && (len(imageFiles) == 0) { + return nil, errors.New("image is required") + } + } + } + + if len(imageFiles) == 0 { + return nil, errors.New("image is required") + } + + //if len(imageFiles) > 1 { + // return nil, errors.New("only one image is supported for qwen edit") + //} + + // 获取base64编码的图片 + var imageBase64s []string + for _, file := range imageFiles { + image, err := file.Open() + if err != nil { + return nil, errors.New("failed to open image file") + } + + // 读取文件内容 + imageData, err := io.ReadAll(image) + if err != nil { + return nil, errors.New("failed to read image file") + } + + // 获取MIME类型 + mimeType := http.DetectContentType(imageData) + + // 编码为base64 + base64Data := base64.StdEncoding.EncodeToString(imageData) + + // 构造data URL格式 + dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data) + imageBase64s = append(imageBase64s, dataURL) + image.Close() + } + return imageBase64s, nil +} + +func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) { + var imageRequest AliImageRequest + imageRequest.Model = request.Model + imageRequest.ResponseFormat = request.ResponseFormat + + imageBase64s, err := getImageBase64sFromForm(c, "image") + if err != nil { + return nil, fmt.Errorf("get image base64s from form failed: %w", err) + } + //dto.MediaContent{} + mediaContents := make([]AliMediaContent, len(imageBase64s)) + for i, b64 := range imageBase64s { + mediaContents[i] = AliMediaContent{ + Image: b64, + } + } + mediaContents = append(mediaContents, AliMediaContent{ + Text: request.Prompt, + }) + imageRequest.Input = AliImageInput{ + Messages: []AliMessage{ + { + Role: "user", + Content: mediaContents, + }, + }, + } + imageRequest.Parameters = AliImageParameters{ + Watermark: request.Watermark, + } + return &imageRequest, nil +} + +func updateTask(info *relaycommon.RelayInfo, taskID string) (*AliResponse, error, []byte) { + url := fmt.Sprintf("%s/api/v1/tasks/%s", info.ChannelBaseUrl, taskID) + + var aliResponse AliResponse + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return &aliResponse, err, nil + } + + req.Header.Set("Authorization", "Bearer "+info.ApiKey) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + common.SysLog("updateTask client.Do err: " + err.Error()) + return &aliResponse, err, nil + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + + var response AliResponse + err = common.Unmarshal(responseBody, &response) + if err != nil { + common.SysLog("updateTask NewDecoder err: " + err.Error()) + return &aliResponse, err, nil + } + + return &response, nil, responseBody +} + +func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) (*AliResponse, []byte, error) { + waitSeconds := 10 + step := 0 + maxStep := 20 + + var taskResponse AliResponse + var responseBody []byte + + for { + logger.LogDebug(c, fmt.Sprintf("asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds)) + step++ + rsp, err, body := updateTask(info, taskID) + responseBody = body + if err != nil { + logger.LogWarn(c, "asyncTaskWait UpdateTask err: "+err.Error()) + time.Sleep(time.Duration(waitSeconds) * time.Second) + continue + } + + if rsp.Output.TaskStatus == "" { + return &taskResponse, responseBody, nil + } + + switch rsp.Output.TaskStatus { + case "FAILED": + fallthrough + case "CANCELED": + fallthrough + case "SUCCEEDED": + fallthrough + case "UNKNOWN": + return rsp, responseBody, nil + } + if step >= maxStep { + break + } + time.Sleep(time.Duration(waitSeconds) * time.Second) + } + + return nil, nil, fmt.Errorf("aliAsyncTaskWait timeout") +} + +func responseAli2OpenAIImage(c *gin.Context, response *AliResponse, originBody []byte, info *relaycommon.RelayInfo, responseFormat string) *dto.ImageResponse { + imageResponse := dto.ImageResponse{ + Created: info.StartTime.Unix(), + } + + for _, data := range response.Output.Results { + var b64Json string + if responseFormat == "b64_json" { + _, b64, err := service.GetImageFromUrl(data.Url) + if err != nil { + logger.LogError(c, "get_image_data_failed: "+err.Error()) + continue + } + b64Json = b64 + } else { + b64Json = data.B64Image + } + + imageResponse.Data = append(imageResponse.Data, dto.ImageData{ + Url: data.Url, + B64Json: b64Json, + RevisedPrompt: "", + }) + } + var mapResponse map[string]any + _ = common.Unmarshal(originBody, &mapResponse) + imageResponse.Extra = mapResponse + return &imageResponse +} + +func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { + responseFormat := c.GetString("response_format") + + var aliTaskResponse AliResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil + } + service.CloseResponseBodyGracefully(resp) + err = common.Unmarshal(responseBody, &aliTaskResponse) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil + } + + if aliTaskResponse.Message != "" { + logger.LogError(c, "ali_async_task_failed: "+aliTaskResponse.Message) + return types.NewError(errors.New(aliTaskResponse.Message), types.ErrorCodeBadResponse), nil + } + + aliResponse, originRespBody, err := asyncTaskWait(c, info, aliTaskResponse.Output.TaskId) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponse), nil + } + + if aliResponse.Output.TaskStatus != "SUCCEEDED" { + return types.WithOpenAIError(types.OpenAIError{ + Message: aliResponse.Output.Message, + Type: "ali_error", + Param: "", + Code: aliResponse.Output.Code, + }, resp.StatusCode), nil + } + + fullTextResponse := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat) + jsonResponse, err := common.Marshal(fullTextResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + service.IOCopyBytesGracefully(c, resp, jsonResponse) + return nil, &dto.Usage{} +} + +func aliImageEditHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { + var aliResponse AliResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil + } + + service.CloseResponseBodyGracefully(resp) + err = common.Unmarshal(responseBody, &aliResponse) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil + } + + if aliResponse.Message != "" { + logger.LogError(c, "ali_task_failed: "+aliResponse.Message) + return types.NewError(errors.New(aliResponse.Message), types.ErrorCodeBadResponse), nil + } + var fullTextResponse dto.ImageResponse + if len(aliResponse.Output.Choices) > 0 { + fullTextResponse = dto.ImageResponse{ + Created: info.StartTime.Unix(), + Data: []dto.ImageData{ + { + Url: aliResponse.Output.Choices[0]["message"].(map[string]any)["content"].([]any)[0].(map[string]any)["image"].(string), + B64Json: "", + }, + }, + } + } + + var mapResponse map[string]any + _ = common.Unmarshal(responseBody, &mapResponse) + fullTextResponse.Extra = mapResponse + jsonResponse, err := common.Marshal(fullTextResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + service.IOCopyBytesGracefully(c, resp, jsonResponse) + return nil, &dto.Usage{} +} diff --git a/relay/channel/ali/image_wan.go b/relay/channel/ali/image_wan.go new file mode 100644 index 0000000000000000000000000000000000000000..4bd1a27016d6bb382d0260aa695f8bd5c33e1c43 --- /dev/null +++ b/relay/channel/ali/image_wan.go @@ -0,0 +1,39 @@ +package ali + +import ( + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + + "github.com/gin-gonic/gin" +) + +func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) { + var err error + var imageRequest AliImageRequest + imageRequest.Model = request.Model + imageRequest.ResponseFormat = request.ResponseFormat + wanInput := WanImageInput{ + Prompt: request.Prompt, + } + + if err := common.UnmarshalBodyReusable(c, &wanInput); err != nil { + return nil, err + } + if wanInput.Images, err = getImageBase64sFromForm(c, "image"); err != nil { + return nil, fmt.Errorf("get image base64s from form failed: %w", err) + } + wanParams := WanImageParameters{ + N: int(request.N), + } + imageRequest.Input = wanInput + imageRequest.Parameters = wanParams + return &imageRequest, nil +} + +func isWanModel(modelName string) bool { + return strings.Contains(modelName, "wan") +} diff --git a/relay/channel/ali/rerank.go b/relay/channel/ali/rerank.go new file mode 100644 index 0000000000000000000000000000000000000000..1323fc83010f17cc7538027239d2d36d8ab6229b --- /dev/null +++ b/relay/channel/ali/rerank.go @@ -0,0 +1,75 @@ +package ali + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func ConvertRerankRequest(request dto.RerankRequest) *AliRerankRequest { + returnDocuments := request.ReturnDocuments + if returnDocuments == nil { + t := true + returnDocuments = &t + } + return &AliRerankRequest{ + Model: request.Model, + Input: AliRerankInput{ + Query: request.Query, + Documents: request.Documents, + }, + Parameters: AliRerankParameters{ + TopN: &request.TopN, + ReturnDocuments: returnDocuments, + }, + } +} + +func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil + } + service.CloseResponseBodyGracefully(resp) + + var aliResponse AliRerankResponse + err = json.Unmarshal(responseBody, &aliResponse) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil + } + + if aliResponse.Code != "" { + return types.WithOpenAIError(types.OpenAIError{ + Message: aliResponse.Message, + Type: aliResponse.Code, + Param: aliResponse.RequestId, + Code: aliResponse.Code, + }, resp.StatusCode), nil + } + + usage := dto.Usage{ + PromptTokens: aliResponse.Usage.TotalTokens, + CompletionTokens: 0, + TotalTokens: aliResponse.Usage.TotalTokens, + } + rerankResponse := dto.RerankResponse{ + Results: aliResponse.Output.Results, + Usage: usage, + } + + jsonResponse, err := json.Marshal(rerankResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + c.Writer.Write(jsonResponse) + return nil, &usage +} diff --git a/relay/channel/ali/text.go b/relay/channel/ali/text.go new file mode 100644 index 0000000000000000000000000000000000000000..c169b9b17b9abfb32bca144031dc8cc69987a9dc --- /dev/null +++ b/relay/channel/ali/text.go @@ -0,0 +1,18 @@ +package ali + +import ( + "github.com/QuantumNous/new-api/dto" +) + +// https://help.aliyun.com/document_detail/613695.html?spm=a2c4g.2399480.0.0.1adb778fAdzP9w#341800c0f8w0r + +const EnableSearchModelSuffix = "-internet" + +func requestOpenAI2Ali(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest { + if request.TopP >= 1 { + request.TopP = 0.999 + } else if request.TopP <= 0 { + request.TopP = 0.001 + } + return &request +} diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go new file mode 100644 index 0000000000000000000000000000000000000000..22426c69e9dfdd5488836e568f059e6fe2470b03 --- /dev/null +++ b/relay/channel/api_request.go @@ -0,0 +1,325 @@ +package channel + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + common2 "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func SetupApiRequestHeader(info *common.RelayInfo, c *gin.Context, req *http.Header) { + if info.RelayMode == constant.RelayModeAudioTranscription || info.RelayMode == constant.RelayModeAudioTranslation { + // multipart/form-data + } else if info.RelayMode == constant.RelayModeImagesEdits { + // multipart/form-data + } else if info.RelayMode == constant.RelayModeRealtime { + // websocket + } else { + req.Set("Content-Type", c.Request.Header.Get("Content-Type")) + req.Set("Accept", c.Request.Header.Get("Accept")) + if info.IsStream && c.Request.Header.Get("Accept") == "" { + req.Set("Accept", "text/event-stream") + } + } +} + +// processHeaderOverride 处理请求头覆盖,支持变量替换 +// 支持的变量:{api_key} +func processHeaderOverride(info *common.RelayInfo) (map[string]string, error) { + headerOverride := make(map[string]string) + for k, v := range info.HeadersOverride { + str, ok := v.(string) + if !ok { + return nil, types.NewError(nil, types.ErrorCodeChannelHeaderOverrideInvalid) + } + + // 替换支持的变量 + if strings.Contains(str, "{api_key}") { + str = strings.ReplaceAll(str, "{api_key}", info.ApiKey) + } + + headerOverride[k] = str + } + return headerOverride, nil +} + +func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + fullRequestURL, err := a.GetRequestURL(info) + if err != nil { + return nil, fmt.Errorf("get request url failed: %w", err) + } + if common2.DebugEnabled { + println("fullRequestURL:", fullRequestURL) + } + req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + if err != nil { + return nil, fmt.Errorf("new request failed: %w", err) + } + headers := req.Header + headerOverride, err := processHeaderOverride(info) + if err != nil { + return nil, err + } + for key, value := range headerOverride { + headers.Set(key, value) + } + err = a.SetupRequestHeader(c, &headers, info) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + resp, err := doRequest(c, req, info) + if err != nil { + return nil, fmt.Errorf("do request failed: %w", err) + } + return resp, nil +} + +func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + fullRequestURL, err := a.GetRequestURL(info) + if err != nil { + return nil, fmt.Errorf("get request url failed: %w", err) + } + if common2.DebugEnabled { + println("fullRequestURL:", fullRequestURL) + } + req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + if err != nil { + return nil, fmt.Errorf("new request failed: %w", err) + } + // set form data + req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) + headers := req.Header + headerOverride, err := processHeaderOverride(info) + if err != nil { + return nil, err + } + for key, value := range headerOverride { + headers.Set(key, value) + } + err = a.SetupRequestHeader(c, &headers, info) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + resp, err := doRequest(c, req, info) + if err != nil { + return nil, fmt.Errorf("do request failed: %w", err) + } + return resp, nil +} + +func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*websocket.Conn, error) { + fullRequestURL, err := a.GetRequestURL(info) + if err != nil { + return nil, fmt.Errorf("get request url failed: %w", err) + } + targetHeader := http.Header{} + headerOverride, err := processHeaderOverride(info) + if err != nil { + return nil, err + } + for key, value := range headerOverride { + targetHeader.Set(key, value) + } + err = a.SetupRequestHeader(c, &targetHeader, info) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + targetHeader.Set("Content-Type", c.Request.Header.Get("Content-Type")) + targetConn, _, err := websocket.DefaultDialer.Dial(fullRequestURL, targetHeader) + if err != nil { + return nil, fmt.Errorf("dial failed to %s: %w", fullRequestURL, err) + } + // send request body + //all, err := io.ReadAll(requestBody) + //err = service.WssString(c, targetConn, string(all)) + return targetConn, nil +} + +func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.CancelFunc { + pingerCtx, stopPinger := context.WithCancel(context.Background()) + + gopool.Go(func() { + defer func() { + // 增加panic恢复处理 + if r := recover(); r != nil { + if common2.DebugEnabled { + println("SSE ping goroutine panic recovered:", fmt.Sprintf("%v", r)) + } + } + if common2.DebugEnabled { + println("SSE ping goroutine stopped.") + } + }() + + if pingInterval <= 0 { + pingInterval = helper.DefaultPingInterval + } + + ticker := time.NewTicker(pingInterval) + // 确保在任何情况下都清理ticker + defer func() { + ticker.Stop() + if common2.DebugEnabled { + println("SSE ping ticker stopped") + } + }() + + var pingMutex sync.Mutex + if common2.DebugEnabled { + println("SSE ping goroutine started") + } + + // 增加超时控制,防止goroutine长时间运行 + maxPingDuration := 120 * time.Minute // 最大ping持续时间 + pingTimeout := time.NewTimer(maxPingDuration) + defer pingTimeout.Stop() + + for { + select { + // 发送 ping 数据 + case <-ticker.C: + if err := sendPingData(c, &pingMutex); err != nil { + if common2.DebugEnabled { + println("SSE ping error, stopping goroutine:", err.Error()) + } + return + } + // 收到退出信号 + case <-pingerCtx.Done(): + return + // request 结束 + case <-c.Request.Context().Done(): + return + // 超时保护,防止goroutine无限运行 + case <-pingTimeout.C: + if common2.DebugEnabled { + println("SSE ping goroutine timeout, stopping") + } + return + } + } + }) + + return stopPinger +} + +func sendPingData(c *gin.Context, mutex *sync.Mutex) error { + // 增加超时控制,防止锁死等待 + done := make(chan error, 1) + go func() { + mutex.Lock() + defer mutex.Unlock() + + err := helper.PingData(c) + if err != nil { + logger.LogError(c, "SSE ping error: "+err.Error()) + done <- err + return + } + + if common2.DebugEnabled { + println("SSE ping data sent.") + } + done <- nil + }() + + // 设置发送ping数据的超时时间 + select { + case err := <-done: + return err + case <-time.After(10 * time.Second): + return errors.New("SSE ping data send timeout") + case <-c.Request.Context().Done(): + return errors.New("request context cancelled during ping") + } +} + +func DoRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) { + return doRequest(c, req, info) +} +func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) { + var client *http.Client + var err error + if info.ChannelSetting.Proxy != "" { + client, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + } else { + client = service.GetHttpClient() + } + + var stopPinger context.CancelFunc + if info.IsStream { + helper.SetEventStreamHeaders(c) + // 处理流式请求的 ping 保活 + generalSettings := operation_setting.GetGeneralSetting() + if generalSettings.PingIntervalEnabled && !info.DisablePing { + pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second + stopPinger = startPingKeepAlive(c, pingInterval) + // 使用defer确保在任何情况下都能停止ping goroutine + defer func() { + if stopPinger != nil { + stopPinger() + if common2.DebugEnabled { + println("SSE ping goroutine stopped by defer") + } + } + }() + } + } + + resp, err := client.Do(req) + if err != nil { + logger.LogError(c, "do request failed: "+err.Error()) + return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed")) + } + if resp == nil { + return nil, errors.New("resp is nil") + } + + _ = req.Body.Close() + _ = c.Request.Body.Close() + return resp, nil +} + +func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + fullRequestURL, err := a.BuildRequestURL(info) + if err != nil { + return nil, err + } + req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + if err != nil { + return nil, fmt.Errorf("new request failed: %w", err) + } + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(requestBody), nil + } + + err = a.BuildRequestHeader(c, req, info) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + resp, err := doRequest(c, req, info) + if err != nil { + return nil, fmt.Errorf("do request failed: %w", err) + } + return resp, nil +} diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..423cf842d96026ba1a7d694150422e0b45ceb8dd --- /dev/null +++ b/relay/channel/aws/adaptor.go @@ -0,0 +1,182 @@ +package aws + +import ( + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/claude" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + "github.com/pkg/errors" + + "github.com/gin-gonic/gin" +) + +type ClientMode int + +const ( + ClientModeApiKey ClientMode = iota + 1 + ClientModeAKSK +) + +type Adaptor struct { + ClientMode ClientMode + AwsClient *bedrockruntime.Client + AwsModelId string + AwsReq any + IsNova bool +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + for i, message := range request.Messages { + updated := false + if !message.IsStringContent() { + content, err := message.ParseContent() + if err != nil { + return nil, errors.Wrap(err, "failed to parse message content") + } + for i2, mediaMessage := range content { + if mediaMessage.Source != nil { + if mediaMessage.Source.Type == "url" { + fileData, err := service.GetFileBase64FromUrl(c, mediaMessage.Source.Url, "formatting image for Claude") + if err != nil { + return nil, fmt.Errorf("get file base64 from url failed: %s", err.Error()) + } + mediaMessage.Source.MediaType = fileData.MimeType + mediaMessage.Source.Data = fileData.Base64Data + mediaMessage.Source.Url = "" + mediaMessage.Source.Type = "base64" + content[i2] = mediaMessage + updated = true + } + } + } + if updated { + message.SetContent(content) + } + } + if updated { + request.Messages[i] = message + } + } + return request, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.ChannelOtherSettings.AwsKeyType == dto.AwsKeyTypeApiKey { + awsModelId := getAwsModelID(info.UpstreamModelName) + a.ClientMode = ClientModeApiKey + awsSecret := strings.Split(info.ApiKey, "|") + if len(awsSecret) != 2 { + return "", errors.New("invalid aws api key, should be in format of |") + } + return fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com/model/%s/converse", awsModelId, awsSecret[1]), nil + } else { + a.ClientMode = ClientModeAKSK + return "", nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + claude.CommonClaudeHeadersOperation(c, req, info) + if a.ClientMode == ClientModeApiKey { + req.Set("Authorization", "Bearer "+info.ApiKey) + } + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + // 检查是否为Nova模型 + if isNovaModel(request.Model) { + novaReq := convertToNovaRequest(request) + a.IsNova = true + return novaReq, nil + } + + // 原有的Claude模型处理逻辑 + claudeReq, err := claude.RequestOpenAI2ClaudeMessage(c, *request) + if err != nil { + return nil, errors.Wrap(err, "failed to convert openai request to claude request") + } + info.UpstreamModelName = claudeReq.Model + return claudeReq, err +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + if a.ClientMode == ClientModeApiKey { + return channel.DoApiRequest(a, c, info, requestBody) + } else { + return doAwsClientRequest(c, info, a, requestBody) + } +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if a.ClientMode == ClientModeApiKey { + claudeAdaptor := claude.Adaptor{} + usage, err = claudeAdaptor.DoResponse(c, resp, info) + } else { + if a.IsNova { + err, usage = handleNovaRequest(c, info, a) + } else { + if info.IsStream { + err, usage = awsStreamHandler(c, info, a) + } else { + err, usage = awsHandler(c, info, a) + } + } + } + return +} + +func (a *Adaptor) GetModelList() (models []string) { + for n := range awsModelIDMap { + models = append(models, n) + } + + return +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..6323bb3b1302d9ce366be9d1ba397e21d4cacbc1 --- /dev/null +++ b/relay/channel/aws/constants.go @@ -0,0 +1,140 @@ +package aws + +import "strings" + +var awsModelIDMap = map[string]string{ + "claude-instant-1.2": "anthropic.claude-instant-v1", + "claude-2.0": "anthropic.claude-v2", + "claude-2.1": "anthropic.claude-v2:1", + "claude-3-sonnet-20240229": "anthropic.claude-3-sonnet-20240229-v1:0", + "claude-3-opus-20240229": "anthropic.claude-3-opus-20240229-v1:0", + "claude-3-haiku-20240307": "anthropic.claude-3-haiku-20240307-v1:0", + "claude-3-5-sonnet-20240620": "anthropic.claude-3-5-sonnet-20240620-v1:0", + "claude-3-5-sonnet-20241022": "anthropic.claude-3-5-sonnet-20241022-v2:0", + "claude-3-5-haiku-20241022": "anthropic.claude-3-5-haiku-20241022-v1:0", + "claude-3-7-sonnet-20250219": "anthropic.claude-3-7-sonnet-20250219-v1:0", + "claude-sonnet-4-20250514": "anthropic.claude-sonnet-4-20250514-v1:0", + "claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0", + "claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0", + "claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5-20250929-v1:0", + "claude-haiku-4-5-20251001": "anthropic.claude-haiku-4-5-20251001-v1:0", + "claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0", + // Nova models + "nova-micro-v1:0": "amazon.nova-micro-v1:0", + "nova-lite-v1:0": "amazon.nova-lite-v1:0", + "nova-pro-v1:0": "amazon.nova-pro-v1:0", + "nova-premier-v1:0": "amazon.nova-premier-v1:0", + "nova-canvas-v1:0": "amazon.nova-canvas-v1:0", + "nova-reel-v1:0": "amazon.nova-reel-v1:0", + "nova-reel-v1:1": "amazon.nova-reel-v1:1", + "nova-sonic-v1:0": "amazon.nova-sonic-v1:0", +} + +var awsModelCanCrossRegionMap = map[string]map[string]bool{ + "anthropic.claude-3-sonnet-20240229-v1:0": { + "us": true, + "eu": true, + "ap": true, + }, + "anthropic.claude-3-opus-20240229-v1:0": { + "us": true, + }, + "anthropic.claude-3-haiku-20240307-v1:0": { + "us": true, + "eu": true, + "ap": true, + }, + "anthropic.claude-3-5-sonnet-20240620-v1:0": { + "us": true, + "eu": true, + "ap": true, + }, + "anthropic.claude-3-5-sonnet-20241022-v2:0": { + "us": true, + "ap": true, + }, + "anthropic.claude-3-5-haiku-20241022-v1:0": { + "us": true, + }, + "anthropic.claude-3-7-sonnet-20250219-v1:0": { + "us": true, + "ap": true, + "eu": true, + }, + "anthropic.claude-sonnet-4-20250514-v1:0": { + "us": true, + "ap": true, + "eu": true, + }, + "anthropic.claude-opus-4-20250514-v1:0": { + "us": true, + }, + "anthropic.claude-opus-4-1-20250805-v1:0": { + "us": true, + }, + "anthropic.claude-sonnet-4-5-20250929-v1:0": { + "us": true, + "ap": true, + "eu": true, + }, + "anthropic.claude-opus-4-5-20251101-v1:0": { + "us": true, + "ap": true, + "eu": true, + }, + "anthropic.claude-haiku-4-5-20251001-v1:0": { + "us": true, + "ap": true, + "eu": true, + }, + // Nova models - all support three major regions + "amazon.nova-micro-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, + "amazon.nova-lite-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, + "amazon.nova-pro-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, + "amazon.nova-premier-v1:0": { + "us": true, + }, + "amazon.nova-canvas-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, + "amazon.nova-reel-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, + "amazon.nova-reel-v1:1": { + "us": true, + }, + "amazon.nova-sonic-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, +} + +var awsRegionCrossModelPrefixMap = map[string]string{ + "us": "us", + "eu": "eu", + "ap": "apac", +} + +var ChannelName = "aws" + +// 判断是否为Nova模型 +func isNovaModel(modelId string) bool { + return strings.Contains(modelId, "nova-") +} diff --git a/relay/channel/aws/dto.go b/relay/channel/aws/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..b060a593bf9dd63ba2ae057e2925a04fec0dca92 --- /dev/null +++ b/relay/channel/aws/dto.go @@ -0,0 +1,143 @@ +package aws + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" +) + +type AwsClaudeRequest struct { + // AnthropicVersion should be "bedrock-2023-05-31" + AnthropicVersion string `json:"anthropic_version"` + AnthropicBeta json.RawMessage `json:"anthropic_beta,omitempty"` + System any `json:"system,omitempty"` + Messages []dto.ClaudeMessage `json:"messages"` + MaxTokens uint `json:"max_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Tools any `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Thinking *dto.Thinking `json:"thinking,omitempty"` +} + +func formatRequest(requestBody io.Reader, requestHeader http.Header) (*AwsClaudeRequest, error) { + var awsClaudeRequest AwsClaudeRequest + err := common.DecodeJson(requestBody, &awsClaudeRequest) + if err != nil { + return nil, err + } + awsClaudeRequest.AnthropicVersion = "bedrock-2023-05-31" + + // check header anthropic-beta + anthropicBetaValues := requestHeader.Get("anthropic-beta") + if len(anthropicBetaValues) > 0 { + var tempArray []string + tempArray = strings.Split(anthropicBetaValues, ",") + if len(tempArray) > 0 { + betaJson, err := json.Marshal(tempArray) + if err != nil { + return nil, err + } + awsClaudeRequest.AnthropicBeta = betaJson + } + } + logger.LogJson(context.Background(), "json", awsClaudeRequest) + return &awsClaudeRequest, nil +} + +// NovaMessage Nova模型使用messages-v1格式 +type NovaMessage struct { + Role string `json:"role"` + Content []NovaContent `json:"content"` +} + +type NovaContent struct { + Text string `json:"text"` +} + +type NovaRequest struct { + SchemaVersion string `json:"schemaVersion"` // 请求版本,例如 "1.0" + Messages []NovaMessage `json:"messages"` // 对话消息列表 + InferenceConfig *NovaInferenceConfig `json:"inferenceConfig,omitempty"` // 推理配置,可选 +} + +type NovaInferenceConfig struct { + MaxTokens int `json:"maxTokens,omitempty"` // 最大生成的 token 数 + Temperature float64 `json:"temperature,omitempty"` // 随机性 (默认 0.7, 范围 0-1) + TopP float64 `json:"topP,omitempty"` // nucleus sampling (默认 0.9, 范围 0-1) + TopK int `json:"topK,omitempty"` // 限制候选 token 数 (默认 50, 范围 0-128) + StopSequences []string `json:"stopSequences,omitempty"` // 停止生成的序列 +} + +// 转换OpenAI请求为Nova格式 +func convertToNovaRequest(req *dto.GeneralOpenAIRequest) *NovaRequest { + novaMessages := make([]NovaMessage, len(req.Messages)) + for i, msg := range req.Messages { + novaMessages[i] = NovaMessage{ + Role: msg.Role, + Content: []NovaContent{{Text: msg.StringContent()}}, + } + } + + novaReq := &NovaRequest{ + SchemaVersion: "messages-v1", + Messages: novaMessages, + } + + // 设置推理配置 + if req.MaxTokens != 0 || (req.Temperature != nil && *req.Temperature != 0) || req.TopP != 0 || req.TopK != 0 || req.Stop != nil { + novaReq.InferenceConfig = &NovaInferenceConfig{} + if req.MaxTokens != 0 { + novaReq.InferenceConfig.MaxTokens = int(req.MaxTokens) + } + if req.Temperature != nil && *req.Temperature != 0 { + novaReq.InferenceConfig.Temperature = *req.Temperature + } + if req.TopP != 0 { + novaReq.InferenceConfig.TopP = req.TopP + } + if req.TopK != 0 { + novaReq.InferenceConfig.TopK = req.TopK + } + if req.Stop != nil { + if stopSequences := parseStopSequences(req.Stop); len(stopSequences) > 0 { + novaReq.InferenceConfig.StopSequences = stopSequences + } + } + } + + return novaReq +} + +// parseStopSequences 解析停止序列,支持字符串或字符串数组 +func parseStopSequences(stop any) []string { + if stop == nil { + return nil + } + + switch v := stop.(type) { + case string: + if v != "" { + return []string{v} + } + case []string: + return v + case []interface{}: + var sequences []string + for _, item := range v { + if str, ok := item.(string); ok && str != "" { + sequences = append(sequences, str) + } + } + return sequences + } + return nil +} diff --git a/relay/channel/aws/relay-aws.go b/relay/channel/aws/relay-aws.go new file mode 100644 index 0000000000000000000000000000000000000000..0795498ce37d27252ede28ea6efd912bdb1c5d63 --- /dev/null +++ b/relay/channel/aws/relay-aws.go @@ -0,0 +1,287 @@ +package aws + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel/claude" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + bedrockruntimeTypes "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types" + "github.com/aws/smithy-go/auth/bearer" +) + +func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.Client, error) { + var ( + httpClient *http.Client + err error + ) + if info.ChannelSetting.Proxy != "" { + httpClient, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + } else { + httpClient = service.GetHttpClient() + } + + awsSecret := strings.Split(info.ApiKey, "|") + var client *bedrockruntime.Client + switch len(awsSecret) { + case 2: + apiKey := awsSecret[0] + region := awsSecret[1] + client = bedrockruntime.New(bedrockruntime.Options{ + Region: region, + BearerAuthTokenProvider: bearer.StaticTokenProvider{Token: bearer.Token{Value: apiKey}}, + HTTPClient: httpClient, + }) + case 3: + ak := awsSecret[0] + sk := awsSecret[1] + region := awsSecret[2] + client = bedrockruntime.New(bedrockruntime.Options{ + Region: region, + Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(ak, sk, "")), + HTTPClient: httpClient, + }) + default: + return nil, errors.New("invalid aws secret key") + } + + return client, nil +} + +func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor, requestBody io.Reader) (any, error) { + awsCli, err := newAwsClient(c, info) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeChannelAwsClientError) + } + a.AwsClient = awsCli + + // 获取对应的AWS模型ID + awsModelId := getAwsModelID(info.UpstreamModelName) + + awsRegionPrefix := getAwsRegionPrefix(awsCli.Options().Region) + canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix) + if canCrossRegion { + awsModelId = awsModelCrossRegion(awsModelId, awsRegionPrefix) + } + + // init empty request.header + requestHeader := http.Header{} + a.SetupRequestHeader(c, &requestHeader, info) + + if isNovaModel(awsModelId) { + var novaReq *NovaRequest + err = common.DecodeJson(requestBody, &novaReq) + if err != nil { + return nil, types.NewError(errors.Wrap(err, "decode nova request fail"), types.ErrorCodeBadRequestBody) + } + + // 使用InvokeModel API,但使用Nova格式的请求体 + awsReq := &bedrockruntime.InvokeModelInput{ + ModelId: aws.String(awsModelId), + Accept: aws.String("application/json"), + ContentType: aws.String("application/json"), + } + + reqBody, err := common.Marshal(novaReq) + if err != nil { + return nil, types.NewError(errors.Wrap(err, "marshal nova request"), types.ErrorCodeBadResponseBody) + } + awsReq.Body = reqBody + return nil, nil + } else { + awsClaudeReq, err := formatRequest(requestBody, requestHeader) + if err != nil { + return nil, types.NewError(errors.Wrap(err, "format aws request fail"), types.ErrorCodeBadRequestBody) + } + + if info.IsStream { + awsReq := &bedrockruntime.InvokeModelWithResponseStreamInput{ + ModelId: aws.String(awsModelId), + Accept: aws.String("application/json"), + ContentType: aws.String("application/json"), + } + awsReq.Body, err = common.Marshal(awsClaudeReq) + if err != nil { + return nil, types.NewError(errors.Wrap(err, "marshal aws request fail"), types.ErrorCodeBadRequestBody) + } + a.AwsReq = awsReq + return nil, nil + } else { + awsReq := &bedrockruntime.InvokeModelInput{ + ModelId: aws.String(awsModelId), + Accept: aws.String("application/json"), + ContentType: aws.String("application/json"), + } + awsReq.Body, err = common.Marshal(awsClaudeReq) + if err != nil { + return nil, types.NewError(errors.Wrap(err, "marshal aws request fail"), types.ErrorCodeBadRequestBody) + } + a.AwsReq = awsReq + return nil, nil + } + } +} + +func getAwsRegionPrefix(awsRegionId string) string { + parts := strings.Split(awsRegionId, "-") + regionPrefix := "" + if len(parts) > 0 { + regionPrefix = parts[0] + } + return regionPrefix +} + +func awsModelCanCrossRegion(awsModelId, awsRegionPrefix string) bool { + regionSet, exists := awsModelCanCrossRegionMap[awsModelId] + return exists && regionSet[awsRegionPrefix] +} + +func awsModelCrossRegion(awsModelId, awsRegionPrefix string) string { + modelPrefix, find := awsRegionCrossModelPrefixMap[awsRegionPrefix] + if !find { + return awsModelId + } + return modelPrefix + "." + awsModelId +} + +func getAwsModelID(requestModel string) string { + if awsModelIDName, ok := awsModelIDMap[requestModel]; ok { + return awsModelIDName + } + return requestModel +} + +func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types.NewAPIError, *dto.Usage) { + + awsResp, err := a.AwsClient.InvokeModel(c.Request.Context(), a.AwsReq.(*bedrockruntime.InvokeModelInput)) + if err != nil { + return types.NewOpenAIError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeAwsInvokeError, http.StatusInternalServerError), nil + } + + claudeInfo := &claude.ClaudeResponseInfo{ + ResponseId: helper.GetResponseID(c), + Created: common.GetTimestamp(), + Model: info.UpstreamModelName, + ResponseText: strings.Builder{}, + Usage: &dto.Usage{}, + } + + // 复制上游 Content-Type 到客户端响应头 + if awsResp.ContentType != nil && *awsResp.ContentType != "" { + c.Writer.Header().Set("Content-Type", *awsResp.ContentType) + } + + handlerErr := claude.HandleClaudeResponseData(c, info, claudeInfo, nil, awsResp.Body, claude.RequestModeMessage) + if handlerErr != nil { + return handlerErr, nil + } + return nil, claudeInfo.Usage +} + +func awsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types.NewAPIError, *dto.Usage) { + awsResp, err := a.AwsClient.InvokeModelWithResponseStream(c.Request.Context(), a.AwsReq.(*bedrockruntime.InvokeModelWithResponseStreamInput)) + if err != nil { + return types.NewOpenAIError(errors.Wrap(err, "InvokeModelWithResponseStream"), types.ErrorCodeAwsInvokeError, http.StatusInternalServerError), nil + } + stream := awsResp.GetStream() + defer stream.Close() + + claudeInfo := &claude.ClaudeResponseInfo{ + ResponseId: helper.GetResponseID(c), + Created: common.GetTimestamp(), + Model: info.UpstreamModelName, + ResponseText: strings.Builder{}, + Usage: &dto.Usage{}, + } + + for event := range stream.Events() { + switch v := event.(type) { + case *bedrockruntimeTypes.ResponseStreamMemberChunk: + info.SetFirstResponseTime() + respErr := claude.HandleStreamResponseData(c, info, claudeInfo, string(v.Value.Bytes), claude.RequestModeMessage) + if respErr != nil { + return respErr, nil + } + case *bedrockruntimeTypes.UnknownUnionMember: + fmt.Println("unknown tag:", v.Tag) + return types.NewError(errors.New("unknown response type"), types.ErrorCodeInvalidRequest), nil + default: + fmt.Println("union is nil or unknown type") + return types.NewError(errors.New("nil or unknown response type"), types.ErrorCodeInvalidRequest), nil + } + } + + claude.HandleStreamFinalResponse(c, info, claudeInfo, claude.RequestModeMessage) + return nil, claudeInfo.Usage +} + +// Nova模型处理函数 +func handleNovaRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types.NewAPIError, *dto.Usage) { + + awsResp, err := a.AwsClient.InvokeModel(c.Request.Context(), a.AwsReq.(*bedrockruntime.InvokeModelInput)) + if err != nil { + return types.NewError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeChannelAwsClientError), nil + } + + // 解析Nova响应 + var novaResp struct { + Output struct { + Message struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } `json:"message"` + } `json:"output"` + Usage struct { + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + TotalTokens int `json:"totalTokens"` + } `json:"usage"` + } + + if err := json.Unmarshal(awsResp.Body, &novaResp); err != nil { + return types.NewError(errors.Wrap(err, "unmarshal nova response"), types.ErrorCodeBadResponseBody), nil + } + + // 构造OpenAI格式响应 + response := dto.OpenAITextResponse{ + Id: helper.GetResponseID(c), + Object: "chat.completion", + Created: common.GetTimestamp(), + Model: info.UpstreamModelName, + Choices: []dto.OpenAITextResponseChoice{{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: novaResp.Output.Message.Content[0].Text, + }, + FinishReason: "stop", + }}, + Usage: dto.Usage{ + PromptTokens: novaResp.Usage.InputTokens, + CompletionTokens: novaResp.Usage.OutputTokens, + TotalTokens: novaResp.Usage.TotalTokens, + }, + } + + c.JSON(http.StatusOK, response) + return nil, &response.Usage +} diff --git a/relay/channel/baidu/adaptor.go b/relay/channel/baidu/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..b8b4735b3b7dc86866cc10810c35f8ab10f57d3e --- /dev/null +++ b/relay/channel/baidu/adaptor.go @@ -0,0 +1,170 @@ +package baidu + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + // https://cloud.baidu.com/doc/WENXINWORKSHOP/s/clntwmv7t + suffix := "chat/" + if strings.HasPrefix(info.UpstreamModelName, "Embedding") { + suffix = "embeddings/" + } + if strings.HasPrefix(info.UpstreamModelName, "bge-large") { + suffix = "embeddings/" + } + if strings.HasPrefix(info.UpstreamModelName, "tao-8k") { + suffix = "embeddings/" + } + switch info.UpstreamModelName { + case "ERNIE-4.0": + suffix += "completions_pro" + case "ERNIE-Bot-4": + suffix += "completions_pro" + case "ERNIE-Bot": + suffix += "completions" + case "ERNIE-Bot-turbo": + suffix += "eb-instant" + case "ERNIE-Speed": + suffix += "ernie_speed" + case "ERNIE-4.0-8K": + suffix += "completions_pro" + case "ERNIE-3.5-8K": + suffix += "completions" + case "ERNIE-3.5-8K-0205": + suffix += "ernie-3.5-8k-0205" + case "ERNIE-3.5-8K-1222": + suffix += "ernie-3.5-8k-1222" + case "ERNIE-Bot-8K": + suffix += "ernie_bot_8k" + case "ERNIE-3.5-4K-0205": + suffix += "ernie-3.5-4k-0205" + case "ERNIE-Speed-8K": + suffix += "ernie_speed" + case "ERNIE-Speed-128K": + suffix += "ernie-speed-128k" + case "ERNIE-Lite-8K-0922": + suffix += "eb-instant" + case "ERNIE-Lite-8K-0308": + suffix += "ernie-lite-8k" + case "ERNIE-Tiny-8K": + suffix += "ernie-tiny-8k" + case "BLOOMZ-7B": + suffix += "bloomz_7b1" + case "Embedding-V1": + suffix += "embedding-v1" + case "bge-large-zh": + suffix += "bge_large_zh" + case "bge-large-en": + suffix += "bge_large_en" + case "tao-8k": + suffix += "tao_8k" + default: + suffix += strings.ToLower(info.UpstreamModelName) + } + fullRequestURL := fmt.Sprintf("%s/rpc/2.0/ai_custom/v1/wenxinworkshop/%s", info.ChannelBaseUrl, suffix) + var accessToken string + var err error + if accessToken, err = getBaiduAccessToken(info.ApiKey); err != nil { + return "", err + } + fullRequestURL += "?access_token=" + accessToken + return fullRequestURL, nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + switch info.RelayMode { + default: + baiduRequest := requestOpenAI2Baidu(*request) + return baiduRequest, nil + } +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + baiduEmbeddingRequest := embeddingRequestOpenAI2Baidu(request) + return baiduEmbeddingRequest, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + err, usage = baiduStreamHandler(c, info, resp) + } else { + switch info.RelayMode { + case constant.RelayModeEmbeddings: + err, usage = baiduEmbeddingHandler(c, info, resp) + default: + err, usage = baiduHandler(c, info, resp) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/baidu/constants.go b/relay/channel/baidu/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..4691433025ca8173aa9961df52a92864bd75e221 --- /dev/null +++ b/relay/channel/baidu/constants.go @@ -0,0 +1,22 @@ +package baidu + +var ModelList = []string{ + "ERNIE-4.0-8K", + "ERNIE-3.5-8K", + "ERNIE-3.5-8K-0205", + "ERNIE-3.5-8K-1222", + "ERNIE-Bot-8K", + "ERNIE-3.5-4K-0205", + "ERNIE-Speed-8K", + "ERNIE-Speed-128K", + "ERNIE-Lite-8K-0922", + "ERNIE-Lite-8K-0308", + "ERNIE-Tiny-8K", + "BLOOMZ-7B", + "Embedding-V1", + "bge-large-zh", + "bge-large-en", + "tao-8k", +} + +var ChannelName = "baidu" diff --git a/relay/channel/baidu/dto.go b/relay/channel/baidu/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..426f63f54aa299cce8e2231239cf55addd81b165 --- /dev/null +++ b/relay/channel/baidu/dto.go @@ -0,0 +1,79 @@ +package baidu + +import ( + "time" + + "github.com/QuantumNous/new-api/dto" +) + +type BaiduMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type BaiduChatRequest struct { + Messages []BaiduMessage `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + PenaltyScore float64 `json:"penalty_score,omitempty"` + Stream bool `json:"stream,omitempty"` + System string `json:"system,omitempty"` + DisableSearch bool `json:"disable_search,omitempty"` + EnableCitation bool `json:"enable_citation,omitempty"` + MaxOutputTokens *int `json:"max_output_tokens,omitempty"` + UserId string `json:"user_id,omitempty"` +} + +type Error struct { + ErrorCode int `json:"error_code"` + ErrorMsg string `json:"error_msg"` +} + +type BaiduChatResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Result string `json:"result"` + IsTruncated bool `json:"is_truncated"` + NeedClearHistory bool `json:"need_clear_history"` + Usage dto.Usage `json:"usage"` + Error +} + +type BaiduChatStreamResponse struct { + BaiduChatResponse + SentenceId int `json:"sentence_id"` + IsEnd bool `json:"is_end"` +} + +type BaiduEmbeddingRequest struct { + Input []string `json:"input"` +} + +type BaiduEmbeddingData struct { + Object string `json:"object"` + Embedding []float64 `json:"embedding"` + Index int `json:"index"` +} + +type BaiduEmbeddingResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Data []BaiduEmbeddingData `json:"data"` + Usage dto.Usage `json:"usage"` + Error +} + +type BaiduAccessToken struct { + AccessToken string `json:"access_token"` + Error string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` + ExpiresAt time.Time `json:"-"` +} + +type BaiduTokenResponse struct { + ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` +} diff --git a/relay/channel/baidu/relay-baidu.go b/relay/channel/baidu/relay-baidu.go new file mode 100644 index 0000000000000000000000000000000000000000..8597e50efdbafd85892b48490323221d63ce722f --- /dev/null +++ b/relay/channel/baidu/relay-baidu.go @@ -0,0 +1,246 @@ +package baidu + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/flfmc9do2 + +var baiduTokenStore sync.Map + +func requestOpenAI2Baidu(request dto.GeneralOpenAIRequest) *BaiduChatRequest { + baiduRequest := BaiduChatRequest{ + Temperature: request.Temperature, + TopP: request.TopP, + PenaltyScore: request.FrequencyPenalty, + Stream: request.Stream, + DisableSearch: false, + EnableCitation: false, + UserId: request.User, + } + if request.GetMaxTokens() != 0 { + maxTokens := int(request.GetMaxTokens()) + if request.GetMaxTokens() == 1 { + maxTokens = 2 + } + baiduRequest.MaxOutputTokens = &maxTokens + } + for _, message := range request.Messages { + if message.Role == "system" { + baiduRequest.System = message.StringContent() + } else { + baiduRequest.Messages = append(baiduRequest.Messages, BaiduMessage{ + Role: message.Role, + Content: message.StringContent(), + }) + } + } + return &baiduRequest +} + +func responseBaidu2OpenAI(response *BaiduChatResponse) *dto.OpenAITextResponse { + choice := dto.OpenAITextResponseChoice{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: response.Result, + }, + FinishReason: "stop", + } + fullTextResponse := dto.OpenAITextResponse{ + Id: response.Id, + Object: "chat.completion", + Created: response.Created, + Choices: []dto.OpenAITextResponseChoice{choice}, + Usage: response.Usage, + } + return &fullTextResponse +} + +func streamResponseBaidu2OpenAI(baiduResponse *BaiduChatStreamResponse) *dto.ChatCompletionsStreamResponse { + var choice dto.ChatCompletionsStreamResponseChoice + choice.Delta.SetContentString(baiduResponse.Result) + if baiduResponse.IsEnd { + choice.FinishReason = &constant.FinishReasonStop + } + response := dto.ChatCompletionsStreamResponse{ + Id: baiduResponse.Id, + Object: "chat.completion.chunk", + Created: baiduResponse.Created, + Model: "ernie-bot", + Choices: []dto.ChatCompletionsStreamResponseChoice{choice}, + } + return &response +} + +func embeddingRequestOpenAI2Baidu(request dto.EmbeddingRequest) *BaiduEmbeddingRequest { + return &BaiduEmbeddingRequest{ + Input: request.ParseInput(), + } +} + +func embeddingResponseBaidu2OpenAI(response *BaiduEmbeddingResponse) *dto.OpenAIEmbeddingResponse { + openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{ + Object: "list", + Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Data)), + Model: "baidu-embedding", + Usage: response.Usage, + } + for _, item := range response.Data { + openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, dto.OpenAIEmbeddingResponseItem{ + Object: item.Object, + Index: item.Index, + Embedding: item.Embedding, + }) + } + return &openAIEmbeddingResponse +} + +func baiduStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + usage := &dto.Usage{} + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + var baiduResponse BaiduChatStreamResponse + err := common.Unmarshal([]byte(data), &baiduResponse) + if err != nil { + common.SysLog("error unmarshalling stream response: " + err.Error()) + return true + } + if baiduResponse.Usage.TotalTokens != 0 { + usage.TotalTokens = baiduResponse.Usage.TotalTokens + usage.PromptTokens = baiduResponse.Usage.PromptTokens + usage.CompletionTokens = baiduResponse.Usage.TotalTokens - baiduResponse.Usage.PromptTokens + } + response := streamResponseBaidu2OpenAI(&baiduResponse) + err = helper.ObjectData(c, response) + if err != nil { + common.SysLog("error sending stream response: " + err.Error()) + } + return true + }) + service.CloseResponseBodyGracefully(resp) + return nil, usage +} + +func baiduHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + var baiduResponse BaiduChatResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + service.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &baiduResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + if baiduResponse.ErrorMsg != "" { + return types.NewError(fmt.Errorf(baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil + } + fullTextResponse := responseBaidu2OpenAI(&baiduResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func baiduEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + var baiduResponse BaiduEmbeddingResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + service.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &baiduResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + if baiduResponse.ErrorMsg != "" { + return types.NewError(fmt.Errorf(baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil + } + fullTextResponse := embeddingResponseBaidu2OpenAI(&baiduResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func getBaiduAccessToken(apiKey string) (string, error) { + if val, ok := baiduTokenStore.Load(apiKey); ok { + var accessToken BaiduAccessToken + if accessToken, ok = val.(BaiduAccessToken); ok { + // soon this will expire + if time.Now().Add(time.Hour).After(accessToken.ExpiresAt) { + go func() { + _, _ = getBaiduAccessTokenHelper(apiKey) + }() + } + return accessToken.AccessToken, nil + } + } + accessToken, err := getBaiduAccessTokenHelper(apiKey) + if err != nil { + return "", err + } + if accessToken == nil { + return "", errors.New("getBaiduAccessToken return a nil token") + } + return (*accessToken).AccessToken, nil +} + +func getBaiduAccessTokenHelper(apiKey string) (*BaiduAccessToken, error) { + parts := strings.Split(apiKey, "|") + if len(parts) != 2 { + return nil, errors.New("invalid baidu apikey") + } + req, err := http.NewRequest("POST", fmt.Sprintf("https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=%s&client_secret=%s", + parts[0], parts[1]), nil) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + res, err := service.GetHttpClient().Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var accessToken BaiduAccessToken + err = json.NewDecoder(res.Body).Decode(&accessToken) + if err != nil { + return nil, err + } + if accessToken.Error != "" { + return nil, errors.New(accessToken.Error + ": " + accessToken.ErrorDescription) + } + if accessToken.AccessToken == "" { + return nil, errors.New("getBaiduAccessTokenHelper get empty access token") + } + accessToken.ExpiresAt = time.Now().Add(time.Duration(accessToken.ExpiresIn) * time.Second) + baiduTokenStore.Store(apiKey, accessToken) + return &accessToken, nil +} diff --git a/relay/channel/baidu_v2/adaptor.go b/relay/channel/baidu_v2/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..94091e38701d169efa0d39b390abbae8d48e779a --- /dev/null +++ b/relay/channel/baidu_v2/adaptor.go @@ -0,0 +1,130 @@ +package baidu_v2 + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := openai.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + switch info.RelayMode { + case constant.RelayModeChatCompletions: + return fmt.Sprintf("%s/v2/chat/completions", info.ChannelBaseUrl), nil + case constant.RelayModeEmbeddings: + return fmt.Sprintf("%s/v2/embeddings", info.ChannelBaseUrl), nil + case constant.RelayModeImagesGenerations: + return fmt.Sprintf("%s/v2/images/generations", info.ChannelBaseUrl), nil + case constant.RelayModeImagesEdits: + return fmt.Sprintf("%s/v2/images/edits", info.ChannelBaseUrl), nil + case constant.RelayModeRerank: + return fmt.Sprintf("%s/v2/rerank", info.ChannelBaseUrl), nil + default: + } + return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode) +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + keyParts := strings.Split(info.ApiKey, "|") + if len(keyParts) == 0 || keyParts[0] == "" { + return errors.New("invalid API key: authorization token is required") + } + if len(keyParts) > 1 { + if keyParts[1] != "" { + req.Set("appid", keyParts[1]) + } + } + req.Set("Authorization", "Bearer "+keyParts[0]) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if strings.HasSuffix(info.UpstreamModelName, "-search") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-search") + request.Model = info.UpstreamModelName + if len(request.WebSearch) == 0 { + toMap := request.ToMap() + toMap["web_search"] = map[string]any{ + "enable": true, + "enable_citation": true, + "enable_trace": true, + "enable_status": false, + } + return toMap, nil + } + return request, nil + } + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + adaptor := openai.Adaptor{} + usage, err = adaptor.DoResponse(c, resp, info) + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/baidu_v2/constants.go b/relay/channel/baidu_v2/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..a7cee24890065c8e69d0b17aece08d8e9a019960 --- /dev/null +++ b/relay/channel/baidu_v2/constants.go @@ -0,0 +1,29 @@ +package baidu_v2 + +var ModelList = []string{ + "ernie-4.0-8k-latest", + "ernie-4.0-8k-preview", + "ernie-4.0-8k", + "ernie-4.0-turbo-8k-latest", + "ernie-4.0-turbo-8k-preview", + "ernie-4.0-turbo-8k", + "ernie-4.0-turbo-128k", + "ernie-3.5-8k-preview", + "ernie-3.5-8k", + "ernie-3.5-128k", + "ernie-speed-8k", + "ernie-speed-128k", + "ernie-speed-pro-128k", + "ernie-lite-8k", + "ernie-lite-pro-128k", + "ernie-tiny-8k", + "ernie-char-8k", + "ernie-char-fiction-8k", + "ernie-novel-8k", + "deepseek-v3", + "deepseek-r1", + "deepseek-r1-distill-qwen-32b", + "deepseek-r1-distill-qwen-14b", +} + +var ChannelName = "volcengine" diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..b9b7447f2f39e7c9fae394e6e976dabfce2e73e3 --- /dev/null +++ b/relay/channel/claude/adaptor.go @@ -0,0 +1,133 @@ +package claude + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +const ( + RequestModeCompletion = 1 + RequestModeMessage = 2 +) + +type Adaptor struct { + RequestMode int +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + if strings.HasPrefix(info.UpstreamModelName, "claude-2") || strings.HasPrefix(info.UpstreamModelName, "claude-instant") { + a.RequestMode = RequestModeCompletion + } else { + a.RequestMode = RequestModeMessage + } +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + baseURL := "" + if a.RequestMode == RequestModeMessage { + baseURL = fmt.Sprintf("%s/v1/messages", info.ChannelBaseUrl) + } else { + baseURL = fmt.Sprintf("%s/v1/complete", info.ChannelBaseUrl) + } + if info.IsClaudeBetaQuery { + baseURL = baseURL + "?beta=true" + } + return baseURL, nil +} + +func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) { + // common headers operation + anthropicBeta := c.Request.Header.Get("anthropic-beta") + if anthropicBeta != "" { + req.Set("anthropic-beta", anthropicBeta) + } + model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("x-api-key", info.ApiKey) + anthropicVersion := c.Request.Header.Get("anthropic-version") + if anthropicVersion == "" { + anthropicVersion = "2023-06-01" + } + req.Set("anthropic-version", anthropicVersion) + CommonClaudeHeadersOperation(c, req, info) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if a.RequestMode == RequestModeCompletion { + return RequestOpenAI2ClaudeComplete(*request), nil + } else { + return RequestOpenAI2ClaudeMessage(c, *request) + } +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + return ClaudeStreamHandler(c, resp, info, a.RequestMode) + } else { + return ClaudeHandler(c, resp, info, a.RequestMode) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/claude/constants.go b/relay/channel/claude/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..a31f341620157cfdee5c9eefcbb0ebba268f8b88 --- /dev/null +++ b/relay/channel/claude/constants.go @@ -0,0 +1,28 @@ +package claude + +var ModelList = []string{ + "claude-instant-1.2", + "claude-2", + "claude-2.0", + "claude-2.1", + "claude-3-sonnet-20240229", + "claude-3-opus-20240229", + "claude-3-haiku-20240307", + "claude-3-5-haiku-20241022", + "claude-3-5-sonnet-20240620", + "claude-3-5-sonnet-20241022", + "claude-3-7-sonnet-20250219", + "claude-3-7-sonnet-20250219-thinking", + "claude-sonnet-4-20250514", + "claude-sonnet-4-20250514-thinking", + "claude-opus-4-20250514", + "claude-opus-4-20250514-thinking", + "claude-opus-4-1-20250805", + "claude-opus-4-1-20250805-thinking", + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-5-20250929-thinking", + "claude-opus-4-5-20251101", + "claude-opus-4-5-20251101-thinking", +} + +var ChannelName = "claude" diff --git a/relay/channel/claude/dto.go b/relay/channel/claude/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..894158683986cbfc04586cb4ac74a649390bec01 --- /dev/null +++ b/relay/channel/claude/dto.go @@ -0,0 +1,95 @@ +package claude + +// +//type ClaudeMetadata struct { +// UserId string `json:"user_id"` +//} +// +//type ClaudeMediaMessage struct { +// Type string `json:"type"` +// Text string `json:"text,omitempty"` +// Source *ClaudeMessageSource `json:"source,omitempty"` +// Usage *ClaudeUsage `json:"usage,omitempty"` +// StopReason *string `json:"stop_reason,omitempty"` +// PartialJson string `json:"partial_json,omitempty"` +// Thinking string `json:"thinking,omitempty"` +// Signature string `json:"signature,omitempty"` +// Delta string `json:"delta,omitempty"` +// // tool_calls +// Id string `json:"id,omitempty"` +// Name string `json:"name,omitempty"` +// Input any `json:"input,omitempty"` +// Content string `json:"content,omitempty"` +// ToolUseId string `json:"tool_use_id,omitempty"` +//} +// +//type ClaudeMessageSource struct { +// Type string `json:"type"` +// MediaType string `json:"media_type"` +// Data string `json:"data"` +//} +// +//type ClaudeMessage struct { +// Role string `json:"role"` +// Content any `json:"content"` +//} +// +//type Tool struct { +// Name string `json:"name"` +// Description string `json:"description,omitempty"` +// InputSchema map[string]interface{} `json:"input_schema"` +//} +// +//type InputSchema struct { +// Type string `json:"type"` +// Properties any `json:"properties,omitempty"` +// Required any `json:"required,omitempty"` +//} +// +//type ClaudeRequest struct { +// Model string `json:"model"` +// Prompt string `json:"prompt,omitempty"` +// System string `json:"system,omitempty"` +// Messages []ClaudeMessage `json:"messages,omitempty"` +// MaxTokens uint `json:"max_tokens,omitempty"` +// MaxTokensToSample uint `json:"max_tokens_to_sample,omitempty"` +// StopSequences []string `json:"stop_sequences,omitempty"` +// Temperature *float64 `json:"temperature,omitempty"` +// TopP float64 `json:"top_p,omitempty"` +// TopK int `json:"top_k,omitempty"` +// //ClaudeMetadata `json:"metadata,omitempty"` +// Stream bool `json:"stream,omitempty"` +// Tools any `json:"tools,omitempty"` +// ToolChoice any `json:"tool_choice,omitempty"` +// Thinking *Thinking `json:"thinking,omitempty"` +//} +// +//type Thinking struct { +// Type string `json:"type"` +// BudgetTokens int `json:"budget_tokens"` +//} +// +//type ClaudeError struct { +// Type string `json:"type"` +// Message string `json:"message"` +//} +// +//type ClaudeResponse struct { +// Id string `json:"id"` +// Type string `json:"type"` +// Content []ClaudeMediaMessage `json:"content"` +// Completion string `json:"completion"` +// StopReason string `json:"stop_reason"` +// Model string `json:"model"` +// Error ClaudeError `json:"error"` +// Usage ClaudeUsage `json:"usage"` +// Index int `json:"index"` // stream only +// ContentBlock *ClaudeMediaMessage `json:"content_block"` +// Delta *ClaudeMediaMessage `json:"delta"` // stream only +// Message *ClaudeResponse `json:"message"` // stream only: message_start +//} +// +//type ClaudeUsage struct { +// InputTokens int `json:"input_tokens"` +// OutputTokens int `json:"output_tokens"` +//} diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go new file mode 100644 index 0000000000000000000000000000000000000000..ea6515b227c02aa58d458166c2c617854df8c0ee --- /dev/null +++ b/relay/channel/claude/relay-claude.go @@ -0,0 +1,841 @@ +package claude + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/relay/channel/openrouter" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +const ( + WebSearchMaxUsesLow = 1 + WebSearchMaxUsesMedium = 5 + WebSearchMaxUsesHigh = 10 +) + +func stopReasonClaude2OpenAI(reason string) string { + switch reason { + case "stop_sequence": + return "stop" + case "end_turn": + return "stop" + case "max_tokens": + return "length" + case "tool_use": + return "tool_calls" + default: + return reason + } +} + +func RequestOpenAI2ClaudeComplete(textRequest dto.GeneralOpenAIRequest) *dto.ClaudeRequest { + + claudeRequest := dto.ClaudeRequest{ + Model: textRequest.Model, + Prompt: "", + StopSequences: nil, + Temperature: textRequest.Temperature, + TopP: textRequest.TopP, + TopK: textRequest.TopK, + Stream: textRequest.Stream, + } + if claudeRequest.MaxTokensToSample == 0 { + claudeRequest.MaxTokensToSample = 4096 + } + prompt := "" + for _, message := range textRequest.Messages { + if message.Role == "user" { + prompt += fmt.Sprintf("\n\nHuman: %s", message.StringContent()) + } else if message.Role == "assistant" { + prompt += fmt.Sprintf("\n\nAssistant: %s", message.StringContent()) + } else if message.Role == "system" { + if prompt == "" { + prompt = message.StringContent() + } + } + } + prompt += "\n\nAssistant:" + claudeRequest.Prompt = prompt + return &claudeRequest +} + +func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) { + claudeTools := make([]any, 0, len(textRequest.Tools)) + + for _, tool := range textRequest.Tools { + if params, ok := tool.Function.Parameters.(map[string]any); ok { + claudeTool := dto.Tool{ + Name: tool.Function.Name, + Description: tool.Function.Description, + } + claudeTool.InputSchema = make(map[string]interface{}) + if params["type"] != nil { + claudeTool.InputSchema["type"] = params["type"].(string) + } + claudeTool.InputSchema["properties"] = params["properties"] + claudeTool.InputSchema["required"] = params["required"] + for s, a := range params { + if s == "type" || s == "properties" || s == "required" { + continue + } + claudeTool.InputSchema[s] = a + } + claudeTools = append(claudeTools, &claudeTool) + } + } + + // Web search tool + // https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/web-search-tool + if textRequest.WebSearchOptions != nil { + webSearchTool := dto.ClaudeWebSearchTool{ + Type: "web_search_20250305", + Name: "web_search", + } + + // 处理 user_location + if textRequest.WebSearchOptions.UserLocation != nil { + anthropicUserLocation := &dto.ClaudeWebSearchUserLocation{ + Type: "approximate", // 固定为 "approximate" + } + + // 解析 UserLocation JSON + var userLocationMap map[string]interface{} + if err := json.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil { + // 检查是否有 approximate 字段 + if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok { + if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" { + anthropicUserLocation.Timezone = timezone + } + if country, ok := approximateData["country"].(string); ok && country != "" { + anthropicUserLocation.Country = country + } + if region, ok := approximateData["region"].(string); ok && region != "" { + anthropicUserLocation.Region = region + } + if city, ok := approximateData["city"].(string); ok && city != "" { + anthropicUserLocation.City = city + } + } + } + + webSearchTool.UserLocation = anthropicUserLocation + } + + // 处理 search_context_size 转换为 max_uses + if textRequest.WebSearchOptions.SearchContextSize != "" { + switch textRequest.WebSearchOptions.SearchContextSize { + case "low": + webSearchTool.MaxUses = WebSearchMaxUsesLow + case "medium": + webSearchTool.MaxUses = WebSearchMaxUsesMedium + case "high": + webSearchTool.MaxUses = WebSearchMaxUsesHigh + } + } + + claudeTools = append(claudeTools, &webSearchTool) + } + + claudeRequest := dto.ClaudeRequest{ + Model: textRequest.Model, + MaxTokens: textRequest.GetMaxTokens(), + StopSequences: nil, + Temperature: textRequest.Temperature, + TopP: textRequest.TopP, + TopK: textRequest.TopK, + Stream: textRequest.Stream, + Tools: claudeTools, + } + + // 处理 tool_choice 和 parallel_tool_calls + if textRequest.ToolChoice != nil || textRequest.ParallelTooCalls != nil { + claudeToolChoice := mapToolChoice(textRequest.ToolChoice, textRequest.ParallelTooCalls) + if claudeToolChoice != nil { + claudeRequest.ToolChoice = claudeToolChoice + } + } + + if claudeRequest.MaxTokens == 0 { + claudeRequest.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(textRequest.Model)) + } + + if model_setting.GetClaudeSettings().ThinkingAdapterEnabled && + strings.HasSuffix(textRequest.Model, "-thinking") { + + // 因为BudgetTokens 必须大于1024 + if claudeRequest.MaxTokens < 1280 { + claudeRequest.MaxTokens = 1280 + } + + // BudgetTokens 为 max_tokens 的 80% + claudeRequest.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: common.GetPointer[int](int(float64(claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)), + } + // TODO: 临时处理 + // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking + claudeRequest.TopP = 0 + claudeRequest.Temperature = common.GetPointer[float64](1.0) + if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) { + claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking") + } + } + + if textRequest.ReasoningEffort != "" { + switch textRequest.ReasoningEffort { + case "low": + claudeRequest.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: common.GetPointer[int](1280), + } + case "medium": + claudeRequest.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: common.GetPointer[int](2048), + } + case "high": + claudeRequest.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: common.GetPointer[int](4096), + } + } + } + + // 指定了 reasoning 参数,覆盖 budgetTokens + if textRequest.Reasoning != nil { + var reasoning openrouter.RequestReasoning + if err := common.Unmarshal(textRequest.Reasoning, &reasoning); err != nil { + return nil, err + } + + budgetTokens := reasoning.MaxTokens + if budgetTokens > 0 { + claudeRequest.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: &budgetTokens, + } + } + } + + if textRequest.Stop != nil { + // stop maybe string/array string, convert to array string + switch textRequest.Stop.(type) { + case string: + claudeRequest.StopSequences = []string{textRequest.Stop.(string)} + case []interface{}: + stopSequences := make([]string, 0) + for _, stop := range textRequest.Stop.([]interface{}) { + stopSequences = append(stopSequences, stop.(string)) + } + claudeRequest.StopSequences = stopSequences + } + } + formatMessages := make([]dto.Message, 0) + lastMessage := dto.Message{ + Role: "tool", + } + for i, message := range textRequest.Messages { + if message.Role == "" { + textRequest.Messages[i].Role = "user" + } + fmtMessage := dto.Message{ + Role: message.Role, + Content: message.Content, + } + if message.Role == "tool" { + fmtMessage.ToolCallId = message.ToolCallId + } + if message.Role == "assistant" && message.ToolCalls != nil { + fmtMessage.ToolCalls = message.ToolCalls + } + if lastMessage.Role == message.Role && lastMessage.Role != "tool" { + if lastMessage.IsStringContent() && message.IsStringContent() { + fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\"")) + // delete last message + formatMessages = formatMessages[:len(formatMessages)-1] + } + } + if fmtMessage.Content == nil { + fmtMessage.SetStringContent("...") + } + formatMessages = append(formatMessages, fmtMessage) + lastMessage = fmtMessage + } + + claudeMessages := make([]dto.ClaudeMessage, 0) + isFirstMessage := true + // 初始化system消息数组,用于累积多个system消息 + var systemMessages []dto.ClaudeMediaMessage + + for _, message := range formatMessages { + if message.Role == "system" { + // 根据Claude API规范,system字段使用数组格式更有通用性 + if message.IsStringContent() { + systemMessages = append(systemMessages, dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](message.StringContent()), + }) + } else { + // 支持复合内容的system消息(虽然不常见,但需要考虑完整性) + for _, ctx := range message.ParseContent() { + if ctx.Type == "text" { + systemMessages = append(systemMessages, dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](ctx.Text), + }) + } + // 未来可以在这里扩展对图片等其他类型的支持 + } + } + } else { + if isFirstMessage { + isFirstMessage = false + if message.Role != "user" { + // fix: first message is assistant, add user message + claudeMessage := dto.ClaudeMessage{ + Role: "user", + Content: []dto.ClaudeMediaMessage{ + { + Type: "text", + Text: common.GetPointer[string]("..."), + }, + }, + } + claudeMessages = append(claudeMessages, claudeMessage) + } + } + claudeMessage := dto.ClaudeMessage{ + Role: message.Role, + } + if message.Role == "tool" { + if len(claudeMessages) > 0 && claudeMessages[len(claudeMessages)-1].Role == "user" { + lastMessage := claudeMessages[len(claudeMessages)-1] + if content, ok := lastMessage.Content.(string); ok { + lastMessage.Content = []dto.ClaudeMediaMessage{ + { + Type: "text", + Text: common.GetPointer[string](content), + }, + } + } + lastMessage.Content = append(lastMessage.Content.([]dto.ClaudeMediaMessage), dto.ClaudeMediaMessage{ + Type: "tool_result", + ToolUseId: message.ToolCallId, + Content: message.Content, + }) + claudeMessages[len(claudeMessages)-1] = lastMessage + continue + } else { + claudeMessage.Role = "user" + claudeMessage.Content = []dto.ClaudeMediaMessage{ + { + Type: "tool_result", + ToolUseId: message.ToolCallId, + Content: message.Content, + }, + } + } + } else if message.IsStringContent() && message.ToolCalls == nil { + claudeMessage.Content = message.StringContent() + } else { + claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0) + for _, mediaMessage := range message.ParseContent() { + claudeMediaMessage := dto.ClaudeMediaMessage{ + Type: mediaMessage.Type, + } + if mediaMessage.Type == "text" { + claudeMediaMessage.Text = common.GetPointer[string](mediaMessage.Text) + } else { + imageUrl := mediaMessage.GetImageMedia() + claudeMediaMessage.Type = "image" + claudeMediaMessage.Source = &dto.ClaudeMessageSource{ + Type: "base64", + } + // 判断是否是url + if strings.HasPrefix(imageUrl.Url, "http") { + // 是url,获取图片的类型和base64编码的数据 + fileData, err := service.GetFileBase64FromUrl(c, imageUrl.Url, "formatting image for Claude") + if err != nil { + return nil, fmt.Errorf("get file base64 from url failed: %s", err.Error()) + } + claudeMediaMessage.Source.MediaType = fileData.MimeType + claudeMediaMessage.Source.Data = fileData.Base64Data + } else { + _, format, base64String, err := service.DecodeBase64ImageData(imageUrl.Url) + if err != nil { + return nil, err + } + claudeMediaMessage.Source.MediaType = "image/" + format + claudeMediaMessage.Source.Data = base64String + } + } + claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage) + } + if message.ToolCalls != nil { + for _, toolCall := range message.ParseToolCalls() { + inputObj := make(map[string]any) + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &inputObj); err != nil { + common.SysLog("tool call function arguments is not a map[string]any: " + fmt.Sprintf("%v", toolCall.Function.Arguments)) + continue + } + claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{ + Type: "tool_use", + Id: toolCall.ID, + Name: toolCall.Function.Name, + Input: inputObj, + }) + } + } + claudeMessage.Content = claudeMediaMessages + } + claudeMessages = append(claudeMessages, claudeMessage) + } + } + + // 设置累积的system消息 + if len(systemMessages) > 0 { + claudeRequest.System = systemMessages + } + + claudeRequest.Prompt = "" + claudeRequest.Messages = claudeMessages + return &claudeRequest, nil +} + +func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto.ChatCompletionsStreamResponse { + var response dto.ChatCompletionsStreamResponse + response.Object = "chat.completion.chunk" + response.Model = claudeResponse.Model + response.Choices = make([]dto.ChatCompletionsStreamResponseChoice, 0) + tools := make([]dto.ToolCallResponse, 0) + fcIdx := 0 + if claudeResponse.Index != nil { + fcIdx = *claudeResponse.Index - 1 + if fcIdx < 0 { + fcIdx = 0 + } + } + var choice dto.ChatCompletionsStreamResponseChoice + if reqMode == RequestModeCompletion { + choice.Delta.SetContentString(claudeResponse.Completion) + finishReason := stopReasonClaude2OpenAI(claudeResponse.StopReason) + if finishReason != "null" { + choice.FinishReason = &finishReason + } + } else { + if claudeResponse.Type == "message_start" { + response.Id = claudeResponse.Message.Id + response.Model = claudeResponse.Message.Model + //claudeUsage = &claudeResponse.Message.Usage + choice.Delta.SetContentString("") + choice.Delta.Role = "assistant" + } else if claudeResponse.Type == "content_block_start" { + if claudeResponse.ContentBlock != nil { + // 如果是文本块,尽可能发送首段文本(若存在) + if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != nil { + choice.Delta.SetContentString(*claudeResponse.ContentBlock.Text) + } + if claudeResponse.ContentBlock.Type == "tool_use" { + tools = append(tools, dto.ToolCallResponse{ + Index: common.GetPointer(fcIdx), + ID: claudeResponse.ContentBlock.Id, + Type: "function", + Function: dto.FunctionResponse{ + Name: claudeResponse.ContentBlock.Name, + Arguments: "", + }, + }) + } + } else { + return nil + } + } else if claudeResponse.Type == "content_block_delta" { + if claudeResponse.Delta != nil { + choice.Delta.Content = claudeResponse.Delta.Text + switch claudeResponse.Delta.Type { + case "input_json_delta": + tools = append(tools, dto.ToolCallResponse{ + Type: "function", + Index: common.GetPointer(fcIdx), + Function: dto.FunctionResponse{ + Arguments: *claudeResponse.Delta.PartialJson, + }, + }) + case "signature_delta": + // 加密的不处理 + signatureContent := "\n" + choice.Delta.ReasoningContent = &signatureContent + case "thinking_delta": + choice.Delta.ReasoningContent = claudeResponse.Delta.Thinking + } + } + } else if claudeResponse.Type == "message_delta" { + finishReason := stopReasonClaude2OpenAI(*claudeResponse.Delta.StopReason) + if finishReason != "null" { + choice.FinishReason = &finishReason + } + //claudeUsage = &claudeResponse.Usage + } else if claudeResponse.Type == "message_stop" { + return nil + } else { + return nil + } + } + if len(tools) > 0 { + choice.Delta.Content = nil // compatible with other OpenAI derivative applications, like LobeOpenAICompatibleFactory ... + choice.Delta.ToolCalls = tools + } + response.Choices = append(response.Choices, choice) + + return &response +} + +func ResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse { + choices := make([]dto.OpenAITextResponseChoice, 0) + fullTextResponse := dto.OpenAITextResponse{ + Id: fmt.Sprintf("chatcmpl-%s", common.GetUUID()), + Object: "chat.completion", + Created: common.GetTimestamp(), + } + var responseText string + var responseThinking string + if len(claudeResponse.Content) > 0 { + responseText = claudeResponse.Content[0].GetText() + if claudeResponse.Content[0].Thinking != nil { + responseThinking = *claudeResponse.Content[0].Thinking + } + } + tools := make([]dto.ToolCallResponse, 0) + thinkingContent := "" + + if reqMode == RequestModeCompletion { + choice := dto.OpenAITextResponseChoice{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: strings.TrimPrefix(claudeResponse.Completion, " "), + Name: nil, + }, + FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason), + } + choices = append(choices, choice) + } else { + fullTextResponse.Id = claudeResponse.Id + for _, message := range claudeResponse.Content { + switch message.Type { + case "tool_use": + args, _ := json.Marshal(message.Input) + tools = append(tools, dto.ToolCallResponse{ + ID: message.Id, + Type: "function", // compatible with other OpenAI derivative applications + Function: dto.FunctionResponse{ + Name: message.Name, + Arguments: string(args), + }, + }) + case "thinking": + // 加密的不管, 只输出明文的推理过程 + if message.Thinking != nil { + thinkingContent = *message.Thinking + } + case "text": + responseText = message.GetText() + } + } + } + choice := dto.OpenAITextResponseChoice{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + }, + FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason), + } + choice.SetStringContent(responseText) + if len(responseThinking) > 0 { + choice.ReasoningContent = responseThinking + } + if len(tools) > 0 { + choice.Message.SetToolCalls(tools) + } + choice.Message.ReasoningContent = thinkingContent + fullTextResponse.Model = claudeResponse.Model + choices = append(choices, choice) + fullTextResponse.Choices = choices + return &fullTextResponse +} + +type ClaudeResponseInfo struct { + ResponseId string + Created int64 + Model string + ResponseText strings.Builder + Usage *dto.Usage + Done bool +} + +func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool { + if requestMode == RequestModeCompletion { + claudeInfo.ResponseText.WriteString(claudeResponse.Completion) + } else { + if claudeResponse.Type == "message_start" { + claudeInfo.ResponseId = claudeResponse.Message.Id + claudeInfo.Model = claudeResponse.Message.Model + + // message_start, 获取usage + claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens + claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens + claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens + claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens() + claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens() + claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens + } else if claudeResponse.Type == "content_block_delta" { + if claudeResponse.Delta.Text != nil { + claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text) + } + if claudeResponse.Delta.Thinking != nil { + claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking) + } + } else if claudeResponse.Type == "message_delta" { + // 最终的usage获取 + if claudeResponse.Usage.InputTokens > 0 { + // 不叠加,只取最新的 + claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens + } + claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens + claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens + + // 判断是否完整 + claudeInfo.Done = true + } else if claudeResponse.Type == "content_block_start" { + } else { + return false + } + } + if oaiResponse != nil { + oaiResponse.Id = claudeInfo.ResponseId + oaiResponse.Created = claudeInfo.Created + oaiResponse.Model = claudeInfo.Model + } + return true +} + +func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, data string, requestMode int) *types.NewAPIError { + var claudeResponse dto.ClaudeResponse + err := common.UnmarshalJsonStr(data, &claudeResponse) + if err != nil { + common.SysLog("error unmarshalling stream response: " + err.Error()) + return types.NewError(err, types.ErrorCodeBadResponseBody) + } + if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" { + return types.WithClaudeError(*claudeError, http.StatusInternalServerError) + } + if info.RelayFormat == types.RelayFormatClaude { + FormatClaudeResponseInfo(requestMode, &claudeResponse, nil, claudeInfo) + + if requestMode == RequestModeCompletion { + } else { + if claudeResponse.Type == "message_start" { + // message_start, 获取usage + info.UpstreamModelName = claudeResponse.Message.Model + } else if claudeResponse.Type == "content_block_delta" { + } else if claudeResponse.Type == "message_delta" { + } + } + helper.ClaudeChunkData(c, claudeResponse, data) + } else if info.RelayFormat == types.RelayFormatOpenAI { + response := StreamResponseClaude2OpenAI(requestMode, &claudeResponse) + + if !FormatClaudeResponseInfo(requestMode, &claudeResponse, response, claudeInfo) { + return nil + } + + err = helper.ObjectData(c, response) + if err != nil { + logger.LogError(c, "send_stream_response_failed: "+err.Error()) + } + } + return nil +} + +func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, requestMode int) { + + if requestMode == RequestModeCompletion { + claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens) + } else { + if claudeInfo.Usage.PromptTokens == 0 { + //上游出错 + } + if claudeInfo.Usage.CompletionTokens == 0 || !claudeInfo.Done { + if common.DebugEnabled { + common.SysLog("claude response usage is not complete, maybe upstream error") + } + claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens) + } + } + + if info.RelayFormat == types.RelayFormatClaude { + // + } else if info.RelayFormat == types.RelayFormatOpenAI { + if info.ShouldIncludeUsage { + response := helper.GenerateFinalUsageResponse(claudeInfo.ResponseId, claudeInfo.Created, info.UpstreamModelName, *claudeInfo.Usage) + err := helper.ObjectData(c, response) + if err != nil { + common.SysLog("send final response failed: " + err.Error()) + } + } + helper.Done(c) + } +} + +func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*dto.Usage, *types.NewAPIError) { + claudeInfo := &ClaudeResponseInfo{ + ResponseId: helper.GetResponseID(c), + Created: common.GetTimestamp(), + Model: info.UpstreamModelName, + ResponseText: strings.Builder{}, + Usage: &dto.Usage{}, + } + var err *types.NewAPIError + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + err = HandleStreamResponseData(c, info, claudeInfo, data, requestMode) + if err != nil { + return false + } + return true + }) + if err != nil { + return nil, err + } + + HandleStreamFinalResponse(c, info, claudeInfo, requestMode) + return claudeInfo.Usage, nil +} + +func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, httpResp *http.Response, data []byte, requestMode int) *types.NewAPIError { + var claudeResponse dto.ClaudeResponse + err := common.Unmarshal(data, &claudeResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody) + } + if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" { + return types.WithClaudeError(*claudeError, http.StatusInternalServerError) + } + if requestMode == RequestModeCompletion { + completionTokens := service.CountTextToken(claudeResponse.Completion, info.OriginModelName) + claudeInfo.Usage.PromptTokens = info.PromptTokens + claudeInfo.Usage.CompletionTokens = completionTokens + claudeInfo.Usage.TotalTokens = info.PromptTokens + completionTokens + } else { + claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens + claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens + claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens + claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens + claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens + claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens() + claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Usage.GetCacheCreation1hTokens() + } + var responseData []byte + switch info.RelayFormat { + case types.RelayFormatOpenAI: + openaiResponse := ResponseClaude2OpenAI(requestMode, &claudeResponse) + openaiResponse.Usage = *claudeInfo.Usage + responseData, err = json.Marshal(openaiResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody) + } + case types.RelayFormatClaude: + responseData = data + } + + if claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 { + c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests) + } + + service.IOCopyBytesGracefully(c, httpResp, responseData) + return nil +} + +func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*dto.Usage, *types.NewAPIError) { + defer service.CloseResponseBodyGracefully(resp) + + claudeInfo := &ClaudeResponseInfo{ + ResponseId: helper.GetResponseID(c), + Created: common.GetTimestamp(), + Model: info.UpstreamModelName, + ResponseText: strings.Builder{}, + Usage: &dto.Usage{}, + } + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + if common.DebugEnabled { + println("responseBody: ", string(responseBody)) + } + handleErr := HandleClaudeResponseData(c, info, claudeInfo, resp, responseBody, requestMode) + if handleErr != nil { + return nil, handleErr + } + return claudeInfo.Usage, nil +} + +func mapToolChoice(toolChoice any, parallelToolCalls *bool) *dto.ClaudeToolChoice { + var claudeToolChoice *dto.ClaudeToolChoice + + // 处理 tool_choice 字符串值 + if toolChoiceStr, ok := toolChoice.(string); ok { + switch toolChoiceStr { + case "auto": + claudeToolChoice = &dto.ClaudeToolChoice{ + Type: "auto", + } + case "required": + claudeToolChoice = &dto.ClaudeToolChoice{ + Type: "any", + } + case "none": + claudeToolChoice = &dto.ClaudeToolChoice{ + Type: "none", + } + } + } else if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok { + // 处理 tool_choice 对象值 + if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok { + if toolName, ok := function["name"].(string); ok { + claudeToolChoice = &dto.ClaudeToolChoice{ + Type: "tool", + Name: toolName, + } + } + } + } + + // 处理 parallel_tool_calls + if parallelToolCalls != nil { + if claudeToolChoice == nil { + // 如果没有 tool_choice,但有 parallel_tool_calls,创建默认的 auto 类型 + claudeToolChoice = &dto.ClaudeToolChoice{ + Type: "auto", + } + } + + // 设置 disable_parallel_tool_use + // 如果 parallel_tool_calls 为 true,则 disable_parallel_tool_use 为 false + claudeToolChoice.DisableParallelToolUse = !*parallelToolCalls + } + + return claudeToolChoice +} diff --git a/relay/channel/cloudflare/adaptor.go b/relay/channel/cloudflare/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..af34462383164b4c8f2ac40d7ce594aa9ef60737 --- /dev/null +++ b/relay/channel/cloudflare/adaptor.go @@ -0,0 +1,136 @@ +package cloudflare + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + switch info.RelayMode { + case constant.RelayModeChatCompletions: + return fmt.Sprintf("%s/client/v4/accounts/%s/ai/v1/chat/completions", info.ChannelBaseUrl, info.ApiVersion), nil + case constant.RelayModeEmbeddings: + return fmt.Sprintf("%s/client/v4/accounts/%s/ai/v1/embeddings", info.ChannelBaseUrl, info.ApiVersion), nil + case constant.RelayModeResponses: + return fmt.Sprintf("%s/client/v4/accounts/%s/ai/v1/responses", info.ChannelBaseUrl, info.ApiVersion), nil + default: + return fmt.Sprintf("%s/client/v4/accounts/%s/ai/run/%s", info.ChannelBaseUrl, info.ApiVersion, info.UpstreamModelName), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + switch info.RelayMode { + case constant.RelayModeCompletions: + return convertCf2CompletionsRequest(*request), nil + default: + return request, nil + } +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + // 添加文件字段 + file, _, err := c.Request.FormFile("file") + if err != nil { + return nil, errors.New("file is required") + } + defer file.Close() + // 打开临时文件用于保存上传的文件内容 + requestBody := &bytes.Buffer{} + + // 将上传的文件内容复制到临时文件 + if _, err := io.Copy(requestBody, file); err != nil { + return nil, err + } + return requestBody, nil +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayMode { + case constant.RelayModeEmbeddings: + fallthrough + case constant.RelayModeChatCompletions: + if info.IsStream { + err, usage = cfStreamHandler(c, info, resp) + } else { + err, usage = cfHandler(c, info, resp) + } + case constant.RelayModeResponses: + if info.IsStream { + usage, err = openai.OaiResponsesStreamHandler(c, info, resp) + } else { + usage, err = openai.OaiResponsesHandler(c, info, resp) + } + case constant.RelayModeAudioTranslation: + fallthrough + case constant.RelayModeAudioTranscription: + err, usage = cfSTTHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/cloudflare/constant.go b/relay/channel/cloudflare/constant.go new file mode 100644 index 0000000000000000000000000000000000000000..0e2aec2b5b8efef206aee6c1e1bf8e11fbee43fd --- /dev/null +++ b/relay/channel/cloudflare/constant.go @@ -0,0 +1,39 @@ +package cloudflare + +var ModelList = []string{ + "@cf/meta/llama-3.1-8b-instruct", + "@cf/meta/llama-2-7b-chat-fp16", + "@cf/meta/llama-2-7b-chat-int8", + "@cf/mistral/mistral-7b-instruct-v0.1", + "@hf/thebloke/deepseek-coder-6.7b-base-awq", + "@hf/thebloke/deepseek-coder-6.7b-instruct-awq", + "@cf/deepseek-ai/deepseek-math-7b-base", + "@cf/deepseek-ai/deepseek-math-7b-instruct", + "@cf/thebloke/discolm-german-7b-v1-awq", + "@cf/tiiuae/falcon-7b-instruct", + "@cf/google/gemma-2b-it-lora", + "@hf/google/gemma-7b-it", + "@cf/google/gemma-7b-it-lora", + "@hf/nousresearch/hermes-2-pro-mistral-7b", + "@hf/thebloke/llama-2-13b-chat-awq", + "@cf/meta-llama/llama-2-7b-chat-hf-lora", + "@cf/meta/llama-3-8b-instruct", + "@hf/thebloke/llamaguard-7b-awq", + "@hf/thebloke/mistral-7b-instruct-v0.1-awq", + "@hf/mistralai/mistral-7b-instruct-v0.2", + "@cf/mistral/mistral-7b-instruct-v0.2-lora", + "@hf/thebloke/neural-chat-7b-v3-1-awq", + "@cf/openchat/openchat-3.5-0106", + "@hf/thebloke/openhermes-2.5-mistral-7b-awq", + "@cf/microsoft/phi-2", + "@cf/qwen/qwen1.5-0.5b-chat", + "@cf/qwen/qwen1.5-1.8b-chat", + "@cf/qwen/qwen1.5-14b-chat-awq", + "@cf/qwen/qwen1.5-7b-chat-awq", + "@cf/defog/sqlcoder-7b-2", + "@hf/nexusflow/starling-lm-7b-beta", + "@cf/tinyllama/tinyllama-1.1b-chat-v1.0", + "@hf/thebloke/zephyr-7b-beta-awq", +} + +var ChannelName = "cloudflare" diff --git a/relay/channel/cloudflare/dto.go b/relay/channel/cloudflare/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..7dcb67224fa90886deab6c2f95a9216e039b0291 --- /dev/null +++ b/relay/channel/cloudflare/dto.go @@ -0,0 +1,21 @@ +package cloudflare + +import "github.com/QuantumNous/new-api/dto" + +type CfRequest struct { + Messages []dto.Message `json:"messages,omitempty"` + Lora string `json:"lora,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + Prompt string `json:"prompt,omitempty"` + Raw bool `json:"raw,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` +} + +type CfAudioResponse struct { + Result CfSTTResult `json:"result"` +} + +type CfSTTResult struct { + Text string `json:"text"` +} diff --git a/relay/channel/cloudflare/relay_cloudflare.go b/relay/channel/cloudflare/relay_cloudflare.go new file mode 100644 index 0000000000000000000000000000000000000000..c7b4f2b266abc49e7a8d2cca7f01142f2723f04c --- /dev/null +++ b/relay/channel/cloudflare/relay_cloudflare.go @@ -0,0 +1,151 @@ +package cloudflare + +import ( + "bufio" + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func convertCf2CompletionsRequest(textRequest dto.GeneralOpenAIRequest) *CfRequest { + p, _ := textRequest.Prompt.(string) + return &CfRequest{ + Prompt: p, + MaxTokens: textRequest.GetMaxTokens(), + Stream: textRequest.Stream, + Temperature: textRequest.Temperature, + } +} + +func cfStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + + helper.SetEventStreamHeaders(c) + id := helper.GetResponseID(c) + var responseText string + isFirst := true + + for scanner.Scan() { + data := scanner.Text() + if len(data) < len("data: ") { + continue + } + data = strings.TrimPrefix(data, "data: ") + data = strings.TrimSuffix(data, "\r") + + if data == "[DONE]" { + break + } + + var response dto.ChatCompletionsStreamResponse + err := json.Unmarshal([]byte(data), &response) + if err != nil { + logger.LogError(c, "error_unmarshalling_stream_response: "+err.Error()) + continue + } + for _, choice := range response.Choices { + choice.Delta.Role = "assistant" + responseText += choice.Delta.GetContentString() + } + response.Id = id + response.Model = info.UpstreamModelName + err = helper.ObjectData(c, response) + if isFirst { + isFirst = false + info.FirstResponseTime = time.Now() + } + if err != nil { + logger.LogError(c, "error_rendering_stream_response: "+err.Error()) + } + } + + if err := scanner.Err(); err != nil { + logger.LogError(c, "error_scanning_stream_response: "+err.Error()) + } + usage := service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens) + if info.ShouldIncludeUsage { + response := helper.GenerateFinalUsageResponse(id, info.StartTime.Unix(), info.UpstreamModelName, *usage) + err := helper.ObjectData(c, response) + if err != nil { + logger.LogError(c, "error_rendering_final_usage_response: "+err.Error()) + } + } + helper.Done(c) + + service.CloseResponseBodyGracefully(resp) + + return nil, usage +} + +func cfHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + service.CloseResponseBodyGracefully(resp) + var response dto.TextResponse + err = json.Unmarshal(responseBody, &response) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + response.Model = info.UpstreamModelName + var responseText string + for _, choice := range response.Choices { + responseText += choice.Message.StringContent() + } + usage := service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens) + response.Usage = *usage + response.Id = helper.GetResponseID(c) + jsonResponse, err := json.Marshal(response) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, usage +} + +func cfSTTHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + var cfResp CfAudioResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + service.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &cfResp) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + + audioResp := &dto.AudioResponse{ + Text: cfResp.Result.Text, + } + + jsonResponse, err := json.Marshal(audioResp) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + + usage := &dto.Usage{} + usage.PromptTokens = info.PromptTokens + usage.CompletionTokens = service.CountTextToken(cfResp.Result.Text, info.UpstreamModelName) + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + + return nil, usage +} diff --git a/relay/channel/cohere/adaptor.go b/relay/channel/cohere/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..664eb67841a7dc453ae41393acc57975bd41c50d --- /dev/null +++ b/relay/channel/cohere/adaptor.go @@ -0,0 +1,100 @@ +package cohere + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.RelayMode == constant.RelayModeRerank { + return fmt.Sprintf("%s/v1/rerank", info.ChannelBaseUrl), nil + } else { + return fmt.Sprintf("%s/v1/chat", info.ChannelBaseUrl), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + return requestOpenAI2Cohere(*request), nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return requestConvertRerank2Cohere(request), nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.RelayMode == constant.RelayModeRerank { + usage, err = cohereRerankHandler(c, resp, info) + } else { + if info.IsStream { + usage, err = cohereStreamHandler(c, info, resp) // TODO: fix this + } else { + usage, err = cohereHandler(c, info, resp) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/cohere/constant.go b/relay/channel/cohere/constant.go new file mode 100644 index 0000000000000000000000000000000000000000..f2d2e559bf83d5903ba8526ee4dfaedb67dabe41 --- /dev/null +++ b/relay/channel/cohere/constant.go @@ -0,0 +1,12 @@ +package cohere + +var ModelList = []string{ + "command-a-03-2025", + "command-r", "command-r-plus", + "command-r-08-2024", "command-r-plus-08-2024", + "c4ai-aya-23-35b", "c4ai-aya-23-8b", + "command-light", "command-light-nightly", "command", "command-nightly", + "rerank-english-v3.0", "rerank-multilingual-v3.0", "rerank-english-v2.0", "rerank-multilingual-v2.0", +} + +var ChannelName = "cohere" diff --git a/relay/channel/cohere/dto.go b/relay/channel/cohere/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..2ab6385c24ff8ca04600739f8c6c4fbb5cc95c2a --- /dev/null +++ b/relay/channel/cohere/dto.go @@ -0,0 +1,60 @@ +package cohere + +import "github.com/QuantumNous/new-api/dto" + +type CohereRequest struct { + Model string `json:"model"` + ChatHistory []ChatHistory `json:"chat_history"` + Message string `json:"message"` + Stream bool `json:"stream"` + MaxTokens uint `json:"max_tokens"` + SafetyMode string `json:"safety_mode,omitempty"` +} + +type ChatHistory struct { + Role string `json:"role"` + Message string `json:"message"` +} + +type CohereResponse struct { + IsFinished bool `json:"is_finished"` + EventType string `json:"event_type"` + Text string `json:"text,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` + Response *CohereResponseResult `json:"response"` +} + +type CohereResponseResult struct { + ResponseId string `json:"response_id"` + FinishReason string `json:"finish_reason,omitempty"` + Text string `json:"text"` + Meta CohereMeta `json:"meta"` +} + +type CohereRerankRequest struct { + Documents []any `json:"documents"` + Query string `json:"query"` + Model string `json:"model"` + TopN int `json:"top_n"` + ReturnDocuments bool `json:"return_documents"` +} + +type CohereRerankResponseResult struct { + Results []dto.RerankResponseResult `json:"results"` + Meta CohereMeta `json:"meta"` +} + +type CohereMeta struct { + //Tokens CohereTokens `json:"tokens"` + BilledUnits CohereBilledUnits `json:"billed_units"` +} + +type CohereBilledUnits struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type CohereTokens struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} diff --git a/relay/channel/cohere/relay-cohere.go b/relay/channel/cohere/relay-cohere.go new file mode 100644 index 0000000000000000000000000000000000000000..2e23e386709bcb6de5ca86bd8e23ad3338a6a0a1 --- /dev/null +++ b/relay/channel/cohere/relay-cohere.go @@ -0,0 +1,249 @@ +package cohere + +import ( + "bufio" + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func requestOpenAI2Cohere(textRequest dto.GeneralOpenAIRequest) *CohereRequest { + cohereReq := CohereRequest{ + Model: textRequest.Model, + ChatHistory: []ChatHistory{}, + Message: "", + Stream: textRequest.Stream, + MaxTokens: textRequest.GetMaxTokens(), + } + if common.CohereSafetySetting != "NONE" { + cohereReq.SafetyMode = common.CohereSafetySetting + } + if cohereReq.MaxTokens == 0 { + cohereReq.MaxTokens = 4000 + } + for _, msg := range textRequest.Messages { + if msg.Role == "user" { + cohereReq.Message = msg.StringContent() + } else { + var role string + if msg.Role == "assistant" { + role = "CHATBOT" + } else if msg.Role == "system" { + role = "SYSTEM" + } else { + role = "USER" + } + cohereReq.ChatHistory = append(cohereReq.ChatHistory, ChatHistory{ + Role: role, + Message: msg.StringContent(), + }) + } + } + + return &cohereReq +} + +func requestConvertRerank2Cohere(rerankRequest dto.RerankRequest) *CohereRerankRequest { + if rerankRequest.TopN == 0 { + rerankRequest.TopN = 1 + } + cohereReq := CohereRerankRequest{ + Query: rerankRequest.Query, + Documents: rerankRequest.Documents, + Model: rerankRequest.Model, + TopN: rerankRequest.TopN, + ReturnDocuments: true, + } + return &cohereReq +} + +func stopReasonCohere2OpenAI(reason string) string { + switch reason { + case "COMPLETE": + return "stop" + case "MAX_TOKENS": + return "max_tokens" + default: + return reason + } +} + +func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseId := helper.GetResponseID(c) + createdTime := common.GetTimestamp() + usage := &dto.Usage{} + responseText := "" + scanner := bufio.NewScanner(resp.Body) + scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := strings.Index(string(data), "\n"); i >= 0 { + return i + 1, data[0:i], nil + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil + }) + dataChan := make(chan string) + stopChan := make(chan bool) + go func() { + for scanner.Scan() { + data := scanner.Text() + dataChan <- data + } + stopChan <- true + }() + helper.SetEventStreamHeaders(c) + isFirst := true + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + if isFirst { + isFirst = false + info.FirstResponseTime = time.Now() + } + data = strings.TrimSuffix(data, "\r") + var cohereResp CohereResponse + err := json.Unmarshal([]byte(data), &cohereResp) + if err != nil { + common.SysLog("error unmarshalling stream response: " + err.Error()) + return true + } + var openaiResp dto.ChatCompletionsStreamResponse + openaiResp.Id = responseId + openaiResp.Created = createdTime + openaiResp.Object = "chat.completion.chunk" + openaiResp.Model = info.UpstreamModelName + if cohereResp.IsFinished { + finishReason := stopReasonCohere2OpenAI(cohereResp.FinishReason) + openaiResp.Choices = []dto.ChatCompletionsStreamResponseChoice{ + { + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{}, + Index: 0, + FinishReason: &finishReason, + }, + } + if cohereResp.Response != nil { + usage.PromptTokens = cohereResp.Response.Meta.BilledUnits.InputTokens + usage.CompletionTokens = cohereResp.Response.Meta.BilledUnits.OutputTokens + } + } else { + openaiResp.Choices = []dto.ChatCompletionsStreamResponseChoice{ + { + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Role: "assistant", + Content: &cohereResp.Text, + }, + Index: 0, + }, + } + responseText += cohereResp.Text + } + jsonStr, err := json.Marshal(openaiResp) + if err != nil { + common.SysLog("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonStr)}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + if usage.PromptTokens == 0 { + usage = service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens) + } + return usage, nil +} + +func cohereHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + createdTime := common.GetTimestamp() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + service.CloseResponseBodyGracefully(resp) + var cohereResp CohereResponseResult + err = json.Unmarshal(responseBody, &cohereResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + usage := dto.Usage{} + usage.PromptTokens = cohereResp.Meta.BilledUnits.InputTokens + usage.CompletionTokens = cohereResp.Meta.BilledUnits.OutputTokens + usage.TotalTokens = cohereResp.Meta.BilledUnits.InputTokens + cohereResp.Meta.BilledUnits.OutputTokens + + var openaiResp dto.TextResponse + openaiResp.Id = cohereResp.ResponseId + openaiResp.Created = createdTime + openaiResp.Object = "chat.completion" + openaiResp.Model = info.UpstreamModelName + openaiResp.Usage = usage + + openaiResp.Choices = []dto.OpenAITextResponseChoice{ + { + Index: 0, + Message: dto.Message{Content: cohereResp.Text, Role: "assistant"}, + FinishReason: stopReasonCohere2OpenAI(cohereResp.FinishReason), + }, + } + + jsonResponse, err := json.Marshal(openaiResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return &usage, nil +} + +func cohereRerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + service.CloseResponseBodyGracefully(resp) + var cohereResp CohereRerankResponseResult + err = json.Unmarshal(responseBody, &cohereResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + usage := dto.Usage{} + if cohereResp.Meta.BilledUnits.InputTokens == 0 { + usage.PromptTokens = info.PromptTokens + usage.CompletionTokens = 0 + usage.TotalTokens = info.PromptTokens + } else { + usage.PromptTokens = cohereResp.Meta.BilledUnits.InputTokens + usage.CompletionTokens = cohereResp.Meta.BilledUnits.OutputTokens + usage.TotalTokens = cohereResp.Meta.BilledUnits.InputTokens + cohereResp.Meta.BilledUnits.OutputTokens + } + + var rerankResp dto.RerankResponse + rerankResp.Results = cohereResp.Results + rerankResp.Usage = usage + + jsonResponse, err := json.Marshal(rerankResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return &usage, nil +} diff --git a/relay/channel/coze/adaptor.go b/relay/channel/coze/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..30f229a31ee58e8dee648c37fd9d88705aab59b2 --- /dev/null +++ b/relay/channel/coze/adaptor.go @@ -0,0 +1,139 @@ +package coze + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *common.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +// ConvertAudioRequest implements channel.Adaptor. +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *common.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("not implemented") +} + +// ConvertClaudeRequest implements channel.Adaptor. +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *common.RelayInfo, request *dto.ClaudeRequest) (any, error) { + return nil, errors.New("not implemented") +} + +// ConvertEmbeddingRequest implements channel.Adaptor. +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *common.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return nil, errors.New("not implemented") +} + +// ConvertImageRequest implements channel.Adaptor. +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *common.RelayInfo, request dto.ImageRequest) (any, error) { + return nil, errors.New("not implemented") +} + +// ConvertOpenAIRequest implements channel.Adaptor. +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *common.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return convertCozeChatRequest(c, *request), nil +} + +// ConvertOpenAIResponsesRequest implements channel.Adaptor. +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *common.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("not implemented") +} + +// ConvertRerankRequest implements channel.Adaptor. +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, errors.New("not implemented") +} + +// DoRequest implements channel.Adaptor. +func (a *Adaptor) DoRequest(c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (any, error) { + if info.IsStream { + return channel.DoApiRequest(a, c, info, requestBody) + } + // 首先发送创建消息请求,成功后再发送获取消息请求 + // 发送创建消息请求 + resp, err := channel.DoApiRequest(a, c, info, requestBody) + if err != nil { + return nil, err + } + // 解析 resp + var cozeResponse CozeChatResponse + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(respBody, &cozeResponse) + if cozeResponse.Code != 0 { + return nil, errors.New(cozeResponse.Msg) + } + c.Set("coze_conversation_id", cozeResponse.Data.ConversationId) + c.Set("coze_chat_id", cozeResponse.Data.Id) + // 轮询检查消息是否完成 + for { + err, isComplete := checkIfChatComplete(a, c, info) + if err != nil { + return nil, err + } else { + if isComplete { + break + } + } + time.Sleep(time.Second * 1) + } + // 发送获取消息请求 + return getChatDetail(a, c, info) +} + +// DoResponse implements channel.Adaptor. +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *common.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = cozeChatStreamHandler(c, info, resp) + } else { + usage, err = cozeChatHandler(c, info, resp) + } + return +} + +// GetChannelName implements channel.Adaptor. +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +// GetModelList implements channel.Adaptor. +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +// GetRequestURL implements channel.Adaptor. +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + return fmt.Sprintf("%s/v3/chat", info.ChannelBaseUrl), nil +} + +// Init implements channel.Adaptor. +func (a *Adaptor) Init(info *common.RelayInfo) { + +} + +// SetupRequestHeader implements channel.Adaptor. +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *common.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} diff --git a/relay/channel/coze/constants.go b/relay/channel/coze/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..873ffe24fc232c844233732c7a54b8050cac3c05 --- /dev/null +++ b/relay/channel/coze/constants.go @@ -0,0 +1,30 @@ +package coze + +var ModelList = []string{ + "moonshot-v1-8k", + "moonshot-v1-32k", + "moonshot-v1-128k", + "Baichuan4", + "abab6.5s-chat-pro", + "glm-4-0520", + "qwen-max", + "deepseek-r1", + "deepseek-v3", + "deepseek-r1-distill-qwen-32b", + "deepseek-r1-distill-qwen-7b", + "step-1v-8k", + "step-1.5v-mini", + "Doubao-pro-32k", + "Doubao-pro-256k", + "Doubao-lite-128k", + "Doubao-lite-32k", + "Doubao-vision-lite-32k", + "Doubao-vision-pro-32k", + "Doubao-1.5-pro-vision-32k", + "Doubao-1.5-lite-32k", + "Doubao-1.5-pro-32k", + "Doubao-1.5-thinking-pro", + "Doubao-1.5-pro-256k", +} + +var ChannelName = "coze" diff --git a/relay/channel/coze/dto.go b/relay/channel/coze/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..d5dc9a813a68efcb98b037ccf5aea72c55790d9b --- /dev/null +++ b/relay/channel/coze/dto.go @@ -0,0 +1,78 @@ +package coze + +import "encoding/json" + +type CozeError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type CozeEnterMessage struct { + Role string `json:"role"` + Type string `json:"type,omitempty"` + Content any `json:"content,omitempty"` + MetaData json.RawMessage `json:"meta_data,omitempty"` + ContentType string `json:"content_type,omitempty"` +} + +type CozeChatRequest struct { + BotId string `json:"bot_id"` + UserId string `json:"user_id"` + AdditionalMessages []CozeEnterMessage `json:"additional_messages,omitempty"` + Stream bool `json:"stream,omitempty"` + CustomVariables json.RawMessage `json:"custom_variables,omitempty"` + AutoSaveHistory bool `json:"auto_save_history,omitempty"` + MetaData json.RawMessage `json:"meta_data,omitempty"` + ExtraParams json.RawMessage `json:"extra_params,omitempty"` + ShortcutCommand json.RawMessage `json:"shortcut_command,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` +} + +type CozeChatResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data CozeChatResponseData `json:"data"` +} + +type CozeChatResponseData struct { + Id string `json:"id"` + ConversationId string `json:"conversation_id"` + BotId string `json:"bot_id"` + CreatedAt int64 `json:"created_at"` + LastError CozeError `json:"last_error"` + Status string `json:"status"` + Usage CozeChatUsage `json:"usage"` +} + +type CozeChatUsage struct { + TokenCount int `json:"token_count"` + OutputCount int `json:"output_count"` + InputCount int `json:"input_count"` +} + +type CozeChatDetailResponse struct { + Data []CozeChatV3MessageDetail `json:"data"` + Code int `json:"code"` + Msg string `json:"msg"` + Detail CozeResponseDetail `json:"detail"` +} + +type CozeChatV3MessageDetail struct { + Id string `json:"id"` + Role string `json:"role"` + Type string `json:"type"` + BotId string `json:"bot_id"` + ChatId string `json:"chat_id"` + Content json.RawMessage `json:"content"` + MetaData json.RawMessage `json:"meta_data"` + CreatedAt int64 `json:"created_at"` + SectionId string `json:"section_id"` + UpdatedAt int64 `json:"updated_at"` + ContentType string `json:"content_type"` + ConversationId string `json:"conversation_id"` + ReasoningContent string `json:"reasoning_content"` +} + +type CozeResponseDetail struct { + Logid string `json:"logid"` +} diff --git a/relay/channel/coze/relay-coze.go b/relay/channel/coze/relay-coze.go new file mode 100644 index 0000000000000000000000000000000000000000..7095a8b6d195f171c5d6e84c3353a62d35d11e11 --- /dev/null +++ b/relay/channel/coze/relay-coze.go @@ -0,0 +1,297 @@ +package coze + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func convertCozeChatRequest(c *gin.Context, request dto.GeneralOpenAIRequest) *CozeChatRequest { + var messages []CozeEnterMessage + // 将 request的messages的role为user的content转换为CozeMessage + for _, message := range request.Messages { + if message.Role == "user" { + messages = append(messages, CozeEnterMessage{ + Role: "user", + Content: message.Content, + // TODO: support more content type + ContentType: "text", + }) + } + } + user := request.User + if user == "" { + user = helper.GetResponseID(c) + } + cozeRequest := &CozeChatRequest{ + BotId: c.GetString("bot_id"), + UserId: user, + AdditionalMessages: messages, + Stream: request.Stream, + } + return cozeRequest +} + +func cozeChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + service.CloseResponseBodyGracefully(resp) + // convert coze response to openai response + var response dto.TextResponse + var cozeResponse CozeChatDetailResponse + response.Model = info.UpstreamModelName + err = json.Unmarshal(responseBody, &cozeResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + if cozeResponse.Code != 0 { + return nil, types.NewError(errors.New(cozeResponse.Msg), types.ErrorCodeBadResponseBody) + } + // 从上下文获取 usage + var usage dto.Usage + usage.PromptTokens = c.GetInt("coze_input_count") + usage.CompletionTokens = c.GetInt("coze_output_count") + usage.TotalTokens = c.GetInt("coze_token_count") + response.Usage = usage + response.Id = helper.GetResponseID(c) + + var responseContent json.RawMessage + for _, data := range cozeResponse.Data { + if data.Type == "answer" { + responseContent = data.Content + response.Created = data.CreatedAt + } + } + // 添加 response.Choices + response.Choices = []dto.OpenAITextResponseChoice{ + { + Index: 0, + Message: dto.Message{Role: "assistant", Content: responseContent}, + FinishReason: "stop", + }, + } + jsonResponse, err := json.Marshal(response) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + + return &usage, nil +} + +func cozeChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + helper.SetEventStreamHeaders(c) + id := helper.GetResponseID(c) + var responseText string + + var currentEvent string + var currentData string + var usage = &dto.Usage{} + + for scanner.Scan() { + line := scanner.Text() + + if line == "" { + if currentEvent != "" && currentData != "" { + // handle last event + handleCozeEvent(c, currentEvent, currentData, &responseText, usage, id, info) + currentEvent = "" + currentData = "" + } + continue + } + + if strings.HasPrefix(line, "event:") { + currentEvent = strings.TrimSpace(line[6:]) + continue + } + + if strings.HasPrefix(line, "data:") { + currentData = strings.TrimSpace(line[5:]) + continue + } + } + + // Last event + if currentEvent != "" && currentData != "" { + handleCozeEvent(c, currentEvent, currentData, &responseText, usage, id, info) + } + + if err := scanner.Err(); err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + helper.Done(c) + + if usage.TotalTokens == 0 { + usage = service.ResponseText2Usage(c, responseText, info.UpstreamModelName, c.GetInt("coze_input_count")) + } + + return usage, nil +} + +func handleCozeEvent(c *gin.Context, event string, data string, responseText *string, usage *dto.Usage, id string, info *relaycommon.RelayInfo) { + switch event { + case "conversation.chat.completed": + // 将 data 解析为 CozeChatResponseData + var chatData CozeChatResponseData + err := json.Unmarshal([]byte(data), &chatData) + if err != nil { + common.SysLog("error_unmarshalling_stream_response: " + err.Error()) + return + } + + usage.PromptTokens = chatData.Usage.InputCount + usage.CompletionTokens = chatData.Usage.OutputCount + usage.TotalTokens = chatData.Usage.TokenCount + + finishReason := "stop" + stopResponse := helper.GenerateStopResponse(id, common.GetTimestamp(), info.UpstreamModelName, finishReason) + helper.ObjectData(c, stopResponse) + + case "conversation.message.delta": + // 将 data 解析为 CozeChatV3MessageDetail + var messageData CozeChatV3MessageDetail + err := json.Unmarshal([]byte(data), &messageData) + if err != nil { + common.SysLog("error_unmarshalling_stream_response: " + err.Error()) + return + } + + var content string + err = json.Unmarshal(messageData.Content, &content) + if err != nil { + common.SysLog("error_unmarshalling_stream_response: " + err.Error()) + return + } + + *responseText += content + + openaiResponse := dto.ChatCompletionsStreamResponse{ + Id: id, + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: info.UpstreamModelName, + } + + choice := dto.ChatCompletionsStreamResponseChoice{ + Index: 0, + } + choice.Delta.SetContentString(content) + openaiResponse.Choices = append(openaiResponse.Choices, choice) + + helper.ObjectData(c, openaiResponse) + + case "error": + var errorData CozeError + err := json.Unmarshal([]byte(data), &errorData) + if err != nil { + common.SysLog("error_unmarshalling_stream_response: " + err.Error()) + return + } + + common.SysLog(fmt.Sprintf("stream event error: ", errorData.Code, errorData.Message)) + } +} + +func checkIfChatComplete(a *Adaptor, c *gin.Context, info *relaycommon.RelayInfo) (error, bool) { + requestURL := fmt.Sprintf("%s/v3/chat/retrieve", info.ChannelBaseUrl) + + requestURL = requestURL + "?conversation_id=" + c.GetString("coze_conversation_id") + "&chat_id=" + c.GetString("coze_chat_id") + // 将 conversationId和chatId作为参数发送get请求 + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return err, false + } + err = a.SetupRequestHeader(c, &req.Header, info) + if err != nil { + return err, false + } + + resp, err := doRequest(req, info) // 调用 doRequest + if err != nil { + return err, false + } + if resp == nil { // 确保在 doRequest 失败时 resp 不为 nil 导致 panic + return fmt.Errorf("resp is nil"), false + } + defer resp.Body.Close() // 确保响应体被关闭 + + // 解析 resp 到 CozeChatResponse + var cozeResponse CozeChatResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response body failed: %w", err), false + } + err = json.Unmarshal(responseBody, &cozeResponse) + if err != nil { + return fmt.Errorf("unmarshal response body failed: %w", err), false + } + if cozeResponse.Data.Status == "completed" { + // 在上下文设置 usage + c.Set("coze_token_count", cozeResponse.Data.Usage.TokenCount) + c.Set("coze_output_count", cozeResponse.Data.Usage.OutputCount) + c.Set("coze_input_count", cozeResponse.Data.Usage.InputCount) + return nil, true + } else if cozeResponse.Data.Status == "failed" || cozeResponse.Data.Status == "canceled" || cozeResponse.Data.Status == "requires_action" { + return fmt.Errorf("chat status: %s", cozeResponse.Data.Status), false + } else { + return nil, false + } +} + +func getChatDetail(a *Adaptor, c *gin.Context, info *relaycommon.RelayInfo) (*http.Response, error) { + requestURL := fmt.Sprintf("%s/v3/chat/message/list", info.ChannelBaseUrl) + + requestURL = requestURL + "?conversation_id=" + c.GetString("coze_conversation_id") + "&chat_id=" + c.GetString("coze_chat_id") + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return nil, fmt.Errorf("new request failed: %w", err) + } + err = a.SetupRequestHeader(c, &req.Header, info) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + resp, err := doRequest(req, info) + if err != nil { + return nil, fmt.Errorf("do request failed: %w", err) + } + return resp, nil +} + +func doRequest(req *http.Request, info *relaycommon.RelayInfo) (*http.Response, error) { + var client *http.Client + var err error // 声明 err 变量 + if info.ChannelSetting.Proxy != "" { + client, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + } else { + client = service.GetHttpClient() + } + resp, err := client.Do(req) + if err != nil { // 增加对 client.Do(req) 返回错误的检查 + return nil, fmt.Errorf("client.Do failed: %w", err) + } + // _ = resp.Body.Close() + return resp, nil +} diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..53488f0d57d3887e1e8c40749ff262388fc5dba5 --- /dev/null +++ b/relay/channel/deepseek/adaptor.go @@ -0,0 +1,115 @@ +package deepseek + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/claude" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := claude.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + fimBaseUrl := info.ChannelBaseUrl + switch info.RelayFormat { + case types.RelayFormatClaude: + return fmt.Sprintf("%s/anthropic/v1/messages", info.ChannelBaseUrl), nil + default: + if !strings.HasSuffix(info.ChannelBaseUrl, "/beta") { + fimBaseUrl += "/beta" + } + switch info.RelayMode { + case constant.RelayModeCompletions: + return fmt.Sprintf("%s/completions", fimBaseUrl), nil + default: + return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil + } + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayFormat { + case types.RelayFormatClaude: + if info.IsStream { + return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + } else { + return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) + } + default: + adaptor := openai.Adaptor{} + return adaptor.DoResponse(c, resp, info) + } +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/deepseek/constants.go b/relay/channel/deepseek/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..1d7b1e329ed72d38e92e6f3c086db3bfa8048fdf --- /dev/null +++ b/relay/channel/deepseek/constants.go @@ -0,0 +1,7 @@ +package deepseek + +var ModelList = []string{ + "deepseek-chat", "deepseek-reasoner", +} + +var ChannelName = "deepseek" diff --git a/relay/channel/dify/adaptor.go b/relay/channel/dify/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..4ffee3e60c05dcf9d988bbb7cfb27a7372ddd848 --- /dev/null +++ b/relay/channel/dify/adaptor.go @@ -0,0 +1,121 @@ +package dify + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +const ( + BotTypeChatFlow = 1 // chatflow default + BotTypeAgent = 2 + BotTypeWorkFlow = 3 + BotTypeCompletion = 4 +) + +type Adaptor struct { + BotType int +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + //if strings.HasPrefix(info.UpstreamModelName, "agent") { + // a.BotType = BotTypeAgent + //} else if strings.HasPrefix(info.UpstreamModelName, "workflow") { + // a.BotType = BotTypeWorkFlow + //} else if strings.HasPrefix(info.UpstreamModelName, "chat") { + // a.BotType = BotTypeCompletion + //} else { + //} + a.BotType = BotTypeChatFlow + +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + switch a.BotType { + case BotTypeWorkFlow: + return fmt.Sprintf("%s/v1/workflows/run", info.ChannelBaseUrl), nil + case BotTypeCompletion: + return fmt.Sprintf("%s/v1/completion-messages", info.ChannelBaseUrl), nil + case BotTypeAgent: + fallthrough + default: + return fmt.Sprintf("%s/v1/chat-messages", info.ChannelBaseUrl), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return requestOpenAI2Dify(c, info, *request), nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + return difyStreamHandler(c, info, resp) + } else { + return difyHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/dify/constants.go b/relay/channel/dify/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..db3e67c7993d8e449189c6fdedbeb4e280d2763d --- /dev/null +++ b/relay/channel/dify/constants.go @@ -0,0 +1,5 @@ +package dify + +var ModelList []string + +var ChannelName = "dify" diff --git a/relay/channel/dify/dto.go b/relay/channel/dify/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..cb127554deec5cb2ef08afbcdfc01a511052bcb9 --- /dev/null +++ b/relay/channel/dify/dto.go @@ -0,0 +1,45 @@ +package dify + +import "github.com/QuantumNous/new-api/dto" + +type DifyChatRequest struct { + Inputs map[string]interface{} `json:"inputs"` + Query string `json:"query"` + ResponseMode string `json:"response_mode"` + User string `json:"user"` + AutoGenerateName bool `json:"auto_generate_name"` + Files []DifyFile `json:"files"` +} + +type DifyFile struct { + Type string `json:"type"` + TransferMode string `json:"transfer_mode"` + URL string `json:"url,omitempty"` + UploadFileId string `json:"upload_file_id,omitempty"` +} + +type DifyMetaData struct { + Usage dto.Usage `json:"usage"` +} + +type DifyData struct { + WorkflowId string `json:"workflow_id"` + NodeId string `json:"node_id"` + NodeType string `json:"node_type"` + Status string `json:"status"` +} + +type DifyChatCompletionResponse struct { + ConversationId string `json:"conversation_id"` + Answer string `json:"answer"` + CreateAt int64 `json:"create_at"` + MetaData DifyMetaData `json:"metadata"` +} + +type DifyChunkChatCompletionResponse struct { + Event string `json:"event"` + ConversationId string `json:"conversation_id"` + Answer string `json:"answer"` + Data DifyData `json:"data"` + MetaData DifyMetaData `json:"metadata"` +} diff --git a/relay/channel/dify/relay-dify.go b/relay/channel/dify/relay-dify.go new file mode 100644 index 0000000000000000000000000000000000000000..8f58e86c6bc9eee402a49faca1138c9d8bc34636 --- /dev/null +++ b/relay/channel/dify/relay-dify.go @@ -0,0 +1,290 @@ +package dify + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, media dto.MediaContent) *DifyFile { + uploadUrl := fmt.Sprintf("%s/v1/files/upload", info.ChannelBaseUrl) + switch media.Type { + case dto.ContentTypeImageURL: + // Decode base64 data + imageMedia := media.GetImageMedia() + base64Data := imageMedia.Url + // Remove base64 prefix if exists (e.g., "data:image/jpeg;base64,") + if idx := strings.Index(base64Data, ","); idx != -1 { + base64Data = base64Data[idx+1:] + } + + // Decode base64 string + decodedData, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + common.SysLog("failed to decode base64: " + err.Error()) + return nil + } + + // Create temporary file + tempFile, err := os.CreateTemp("", "dify-upload-*") + if err != nil { + common.SysLog("failed to create temp file: " + err.Error()) + return nil + } + defer tempFile.Close() + defer os.Remove(tempFile.Name()) + + // Write decoded data to temp file + if _, err := tempFile.Write(decodedData); err != nil { + common.SysLog("failed to write to temp file: " + err.Error()) + return nil + } + + // Create multipart form + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Add user field + if err := writer.WriteField("user", user); err != nil { + common.SysLog("failed to add user field: " + err.Error()) + return nil + } + + // Create form file with proper mime type + mimeType := imageMedia.MimeType + if mimeType == "" { + mimeType = "image/jpeg" // default mime type + } + + // Create form file + part, err := writer.CreateFormFile("file", fmt.Sprintf("image.%s", strings.TrimPrefix(mimeType, "image/"))) + if err != nil { + common.SysLog("failed to create form file: " + err.Error()) + return nil + } + + // Copy file content to form + if _, err = io.Copy(part, bytes.NewReader(decodedData)); err != nil { + common.SysLog("failed to copy file content: " + err.Error()) + return nil + } + writer.Close() + + // Create HTTP request + req, err := http.NewRequest("POST", uploadUrl, body) + if err != nil { + common.SysLog("failed to create request: " + err.Error()) + return nil + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) + + // Send request + client := service.GetHttpClient() + resp, err := client.Do(req) + if err != nil { + common.SysLog("failed to send request: " + err.Error()) + return nil + } + defer resp.Body.Close() + + // Parse response + var result struct { + Id string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + common.SysLog("failed to decode response: " + err.Error()) + return nil + } + + return &DifyFile{ + UploadFileId: result.Id, + Type: "image", + TransferMode: "local_file", + } + } + return nil +} + +func requestOpenAI2Dify(c *gin.Context, info *relaycommon.RelayInfo, request dto.GeneralOpenAIRequest) *DifyChatRequest { + difyReq := DifyChatRequest{ + Inputs: make(map[string]interface{}), + AutoGenerateName: false, + } + + user := request.User + if user == "" { + user = helper.GetResponseID(c) + } + difyReq.User = user + + files := make([]DifyFile, 0) + var content strings.Builder + for _, message := range request.Messages { + if message.Role == "system" { + content.WriteString("SYSTEM: \n" + message.StringContent() + "\n") + } else if message.Role == "assistant" { + content.WriteString("ASSISTANT: \n" + message.StringContent() + "\n") + } else { + parseContent := message.ParseContent() + for _, mediaContent := range parseContent { + switch mediaContent.Type { + case dto.ContentTypeText: + content.WriteString("USER: \n" + mediaContent.Text + "\n") + case dto.ContentTypeImageURL: + media := mediaContent.GetImageMedia() + var file *DifyFile + if media.IsRemoteImage() { + file.Type = media.MimeType + file.TransferMode = "remote_url" + file.URL = media.Url + } else { + file = uploadDifyFile(c, info, difyReq.User, mediaContent) + } + if file != nil { + files = append(files, *file) + } + } + } + } + } + difyReq.Query = content.String() + difyReq.Files = files + mode := "blocking" + if request.Stream { + mode = "streaming" + } + difyReq.ResponseMode = mode + return &difyReq +} + +func streamResponseDify2OpenAI(difyResponse DifyChunkChatCompletionResponse) *dto.ChatCompletionsStreamResponse { + response := dto.ChatCompletionsStreamResponse{ + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "dify", + } + var choice dto.ChatCompletionsStreamResponseChoice + if strings.HasPrefix(difyResponse.Event, "workflow_") { + if constant.DifyDebug { + text := "Workflow: " + difyResponse.Data.WorkflowId + if difyResponse.Event == "workflow_finished" { + text += " " + difyResponse.Data.Status + } + choice.Delta.SetReasoningContent(text + "\n") + } + } else if strings.HasPrefix(difyResponse.Event, "node_") { + if constant.DifyDebug { + text := "Node: " + difyResponse.Data.NodeType + if difyResponse.Event == "node_finished" { + text += " " + difyResponse.Data.Status + } + choice.Delta.SetReasoningContent(text + "\n") + } + } else if difyResponse.Event == "message" || difyResponse.Event == "agent_message" { + if difyResponse.Answer == "
Thinking... \n" { + difyResponse.Answer = "" + } else if difyResponse.Answer == "
" { + difyResponse.Answer = "
" + } + + choice.Delta.SetContentString(difyResponse.Answer) + } + response.Choices = append(response.Choices, choice) + return &response +} + +func difyStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var responseText string + usage := &dto.Usage{} + var nodeToken int + helper.SetEventStreamHeaders(c) + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + var difyResponse DifyChunkChatCompletionResponse + err := json.Unmarshal([]byte(data), &difyResponse) + if err != nil { + common.SysLog("error unmarshalling stream response: " + err.Error()) + return true + } + var openaiResponse dto.ChatCompletionsStreamResponse + if difyResponse.Event == "message_end" { + usage = &difyResponse.MetaData.Usage + return false + } else if difyResponse.Event == "error" { + return false + } else { + openaiResponse = *streamResponseDify2OpenAI(difyResponse) + if len(openaiResponse.Choices) != 0 { + responseText += openaiResponse.Choices[0].Delta.GetContentString() + if openaiResponse.Choices[0].Delta.ReasoningContent != nil { + nodeToken += 1 + } + } + } + err = helper.ObjectData(c, openaiResponse) + if err != nil { + common.SysLog(err.Error()) + } + return true + }) + helper.Done(c) + if usage.TotalTokens == 0 { + usage = service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens) + } + usage.CompletionTokens += nodeToken + return usage, nil +} + +func difyHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var difyResponse DifyChatCompletionResponse + responseBody, err := io.ReadAll(resp.Body) + + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + service.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &difyResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + fullTextResponse := dto.OpenAITextResponse{ + Id: difyResponse.ConversationId, + Object: "chat.completion", + Created: common.GetTimestamp(), + Usage: difyResponse.MetaData.Usage, + } + choice := dto.OpenAITextResponseChoice{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: difyResponse.Answer, + }, + FinishReason: "stop", + } + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + c.Writer.Write(jsonResponse) + return &difyResponse.MetaData.Usage, nil +} diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..fcc99b66226bb91a12e8ffdd3842f2b1456b3226 --- /dev/null +++ b/relay/channel/gemini/adaptor.go @@ -0,0 +1,402 @@ +package gemini + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + if len(request.Contents) > 0 { + for i, content := range request.Contents { + if i == 0 { + if request.Contents[0].Role == "" { + request.Contents[0].Role = "user" + } + } + for _, part := range content.Parts { + if part.FileData != nil { + if part.FileData.MimeType == "" && strings.Contains(part.FileData.FileUri, "www.youtube.com") { + part.FileData.MimeType = "video/webm" + } + } + } + } + } + return request, nil +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := openai.Adaptor{} + oaiReq, err := adaptor.ConvertClaudeRequest(c, info, req) + if err != nil { + return nil, err + } + return a.ConvertOpenAIRequest(c, info, oaiReq.(*dto.GeneralOpenAIRequest)) +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +type ImageConfig struct { + AspectRatio string `json:"aspectRatio,omitempty"` + ImageSize string `json:"imageSize,omitempty"` +} + +type SizeMapping struct { + AspectRatio string + ImageSize string +} + +type QualityMapping struct { + Standard string + HD string + High string + FourK string + Auto string +} + +func getImageSizeMapping() QualityMapping { + return QualityMapping{ + Standard: "1K", + HD: "2K", + High: "2K", + FourK: "4K", + Auto: "1K", + } +} + +func getSizeMappings() map[string]SizeMapping { + return map[string]SizeMapping{ + // Gemini 2.5 Flash Image - default 1K resolutions + "1024x1024": {AspectRatio: "1:1", ImageSize: ""}, + "832x1248": {AspectRatio: "2:3", ImageSize: ""}, + "1248x832": {AspectRatio: "3:2", ImageSize: ""}, + "864x1184": {AspectRatio: "3:4", ImageSize: ""}, + "1184x864": {AspectRatio: "4:3", ImageSize: ""}, + "896x1152": {AspectRatio: "4:5", ImageSize: ""}, + "1152x896": {AspectRatio: "5:4", ImageSize: ""}, + "768x1344": {AspectRatio: "9:16", ImageSize: ""}, + "1344x768": {AspectRatio: "16:9", ImageSize: ""}, + "1536x672": {AspectRatio: "21:9", ImageSize: ""}, + + // Gemini 3 Pro Image Preview resolutions + "1536x1024": {AspectRatio: "3:2", ImageSize: ""}, + "1024x1536": {AspectRatio: "2:3", ImageSize: ""}, + "1024x1792": {AspectRatio: "9:16", ImageSize: ""}, + "1792x1024": {AspectRatio: "16:9", ImageSize: ""}, + "2048x2048": {AspectRatio: "1:1", ImageSize: "2K"}, + "4096x4096": {AspectRatio: "1:1", ImageSize: "4K"}, + } +} + +func processSizeParameters(size, quality string) ImageConfig { + config := ImageConfig{} // 默认为空值 + + if size != "" { + if strings.Contains(size, ":") { + config.AspectRatio = size // 直接设置,不与默认值比较 + } else { + if mapping, exists := getSizeMappings()[size]; exists { + if mapping.AspectRatio != "" { + config.AspectRatio = mapping.AspectRatio + } + if mapping.ImageSize != "" { + config.ImageSize = mapping.ImageSize + } + } + } + } + + if quality != "" { + qualityMapping := getImageSizeMapping() + switch strings.ToLower(strings.TrimSpace(quality)) { + case "hd", "high": + config.ImageSize = qualityMapping.HD + case "4k": + config.ImageSize = qualityMapping.FourK + case "standard", "medium", "low", "auto", "1k": + config.ImageSize = qualityMapping.Standard + } + } + + return config +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) { + var content any + if base64Data, err := relaycommon.GetImageBase64sFromForm(c); err == nil { + content = []any{ + dto.MediaContent{ + Type: dto.ContentTypeText, + Text: request.Prompt, + }, + dto.MediaContent{ + Type: dto.ContentTypeFile, + File: &dto.MessageFile{ + FileData: base64Data.String(), + }, + }, + } + } else { + content = request.Prompt + } + + chatRequest := dto.GeneralOpenAIRequest{ + Model: request.Model, + Messages: []dto.Message{ + {Role: "user", Content: content}, + }, + N: int(request.N), + } + + config := processSizeParameters(strings.TrimSpace(request.Size), request.Quality) + googleGenerationConfig := map[string]interface{}{ + "responseModalities": []string{"TEXT", "IMAGE"}, + "imageConfig": config, + } + + extraBody := map[string]interface{}{ + "google": map[string]interface{}{ + "generationConfig": googleGenerationConfig, + }, + } + chatRequest.ExtraBody, _ = json.Marshal(extraBody) + + return a.ConvertOpenAIRequest(c, info, &chatRequest) + } + + // convert size to aspect ratio but allow user to specify aspect ratio + aspectRatio := "1:1" // default aspect ratio + size := strings.TrimSpace(request.Size) + if size != "" { + if strings.Contains(size, ":") { + aspectRatio = size + } else { + if mapping, exists := getSizeMappings()[size]; exists && mapping.AspectRatio != "" { + aspectRatio = mapping.AspectRatio + } + } + } + + // build gemini imagen request + geminiRequest := dto.GeminiImageRequest{ + Instances: []dto.GeminiImageInstance{ + { + Prompt: request.Prompt, + }, + }, + Parameters: dto.GeminiImageParameters{ + SampleCount: int(request.N), + AspectRatio: aspectRatio, + PersonGeneration: "allow_adult", // default allow adult + }, + } + + // Set imageSize when quality parameter is specified + // Map quality parameter to imageSize (only supported by Standard and Ultra models) + // quality values: auto, high, medium, low (for gpt-image-1), hd, standard (for dall-e-3) + // imageSize values: 1K (default), 2K + // https://ai.google.dev/gemini-api/docs/imagen + // https://platform.openai.com/docs/api-reference/images/create + if request.Quality != "" { + imageSize := "1K" // default + switch request.Quality { + case "hd", "high": + imageSize = "2K" + case "2K": + imageSize = "2K" + case "standard", "medium", "low", "auto", "1K": + imageSize = "1K" + default: + // unknown quality value, default to 1K + imageSize = "1K" + } + geminiRequest.Parameters.ImageSize = imageSize + } + + return geminiRequest, nil +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + + if model_setting.GetGeminiSettings().ThinkingAdapterEnabled && + !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) { + // 新增逻辑:处理 -thinking- 格式 + if strings.Contains(info.UpstreamModelName, "-thinking-") { + parts := strings.Split(info.UpstreamModelName, "-thinking-") + info.UpstreamModelName = parts[0] + } else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配 + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") + } else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking") + } + } + + version := model_setting.GetGeminiVersionSetting(info.UpstreamModelName) + + if strings.HasPrefix(info.UpstreamModelName, "imagen") { + return fmt.Sprintf("%s/%s/models/%s:predict", info.ChannelBaseUrl, version, info.UpstreamModelName), nil + } + + if strings.HasPrefix(info.UpstreamModelName, "text-embedding") || + strings.HasPrefix(info.UpstreamModelName, "embedding") || + strings.HasPrefix(info.UpstreamModelName, "gemini-embedding") { + action := "embedContent" + if info.IsGeminiBatchEmbedding { + action = "batchEmbedContents" + } + return fmt.Sprintf("%s/%s/models/%s:%s", info.ChannelBaseUrl, version, info.UpstreamModelName, action), nil + } + + action := "generateContent" + if info.IsStream { + action = "streamGenerateContent?alt=sse" + if info.RelayMode == constant.RelayModeGemini { + info.DisablePing = true + } + } + return fmt.Sprintf("%s/%s/models/%s:%s", info.ChannelBaseUrl, version, info.UpstreamModelName, action), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("x-goog-api-key", info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + geminiRequest, err := CovertOpenAI2Gemini(c, *request, info) + if err != nil { + return nil, err + } + + return geminiRequest, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + if request.Input == nil { + return nil, errors.New("input is required") + } + + inputs := request.ParseInput() + if len(inputs) == 0 { + return nil, errors.New("input is empty") + } + // We always build a batch-style payload with `requests`, so ensure we call the + // batch endpoint upstream to avoid payload/endpoint mismatches. + info.IsGeminiBatchEmbedding = true + // process all inputs + geminiRequests := make([]map[string]interface{}, 0, len(inputs)) + for _, input := range inputs { + geminiRequest := map[string]interface{}{ + "model": fmt.Sprintf("models/%s", info.UpstreamModelName), + "content": dto.GeminiChatContent{ + Parts: []dto.GeminiPart{ + { + Text: input, + }, + }, + }, + } + + // set specific parameters for different models + // https://ai.google.dev/api/embeddings?hl=zh-cn#method:-models.embedcontent + switch info.UpstreamModelName { + case "text-embedding-004", "gemini-embedding-exp-03-07", "gemini-embedding-001": + // Only newer models introduced after 2024 support OutputDimensionality + if request.Dimensions > 0 { + geminiRequest["outputDimensionality"] = request.Dimensions + } + } + geminiRequests = append(geminiRequests, geminiRequest) + } + + return map[string]interface{}{ + "requests": geminiRequests, + }, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.RelayMode == constant.RelayModeGemini { + if strings.Contains(info.RequestURLPath, ":embedContent") || + strings.Contains(info.RequestURLPath, ":batchEmbedContents") { + return NativeGeminiEmbeddingHandler(c, resp, info) + } + if info.IsStream { + return GeminiTextGenerationStreamHandler(c, info, resp) + } else { + return GeminiTextGenerationHandler(c, info, resp) + } + } + + if strings.HasPrefix(info.UpstreamModelName, "imagen") { + return GeminiImageHandler(c, info, resp) + } + + if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) { + return ChatImageHandler(c, info, resp) + } + + // check if the model is an embedding model + if strings.HasPrefix(info.UpstreamModelName, "text-embedding") || + strings.HasPrefix(info.UpstreamModelName, "embedding") || + strings.HasPrefix(info.UpstreamModelName, "gemini-embedding") { + return GeminiEmbeddingHandler(c, info, resp) + } + + if info.IsStream { + return GeminiChatStreamHandler(c, info, resp) + } else { + return GeminiChatHandler(c, info, resp) + } + +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/gemini/constant.go b/relay/channel/gemini/constant.go new file mode 100644 index 0000000000000000000000000000000000000000..59163cc43f886a87bac02f803be8729f486e9775 --- /dev/null +++ b/relay/channel/gemini/constant.go @@ -0,0 +1,38 @@ +package gemini + +var ModelList = []string{ + // stable version + "gemini-1.5-pro", "gemini-1.5-flash", "gemini-1.5-flash-8b", + "gemini-2.0-flash", + // latest version + "gemini-1.5-pro-latest", "gemini-1.5-flash-latest", + // preview version + "gemini-2.0-flash-lite-preview", + "gemini-3-pro-preview", + // gemini exp + "gemini-exp-1206", + // flash exp + "gemini-2.0-flash-exp", + // pro exp + "gemini-2.0-pro-exp", + // thinking exp + "gemini-2.0-flash-thinking-exp", + "gemini-2.5-pro-exp-03-25", + "gemini-2.5-pro-preview-03-25", + // imagen models + "imagen-3.0-generate-002", + // embedding models + "gemini-embedding-exp-03-07", + "text-embedding-004", + "embedding-001", +} + +var SafetySettingList = []string{ + "HARM_CATEGORY_HARASSMENT", + "HARM_CATEGORY_HATE_SPEECH", + "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "HARM_CATEGORY_DANGEROUS_CONTENT", + //"HARM_CATEGORY_CIVIC_INTEGRITY", This item is deprecated! +} + +var ChannelName = "google gemini" diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go new file mode 100644 index 0000000000000000000000000000000000000000..c3f7aa0a61cdfd7f9916f97a91532d50dee2a7f8 --- /dev/null +++ b/relay/channel/gemini/relay-gemini-native.go @@ -0,0 +1,111 @@ +package gemini + +import ( + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer service.CloseResponseBodyGracefully(resp) + + // 读取响应体 + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if common.DebugEnabled { + println(string(responseBody)) + } + + // 解析为 Gemini 原生响应格式 + var geminiResponse dto.GeminiChatResponse + err = common.Unmarshal(responseBody, &geminiResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + // 计算使用量(基于 UsageMetadata) + usage := dto.Usage{ + PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount, + CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount, + TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount, + } + + usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount + + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } + + service.IOCopyBytesGracefully(c, resp, responseBody) + + return &usage, nil +} + +func NativeGeminiEmbeddingHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) { + defer service.CloseResponseBodyGracefully(resp) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if common.DebugEnabled { + println(string(responseBody)) + } + + usage := &dto.Usage{ + PromptTokens: info.PromptTokens, + TotalTokens: info.PromptTokens, + } + + common.SetContextKey(c, constant.ContextKeyLocalCountTokens, true) + + if info.IsGeminiBatchEmbedding { + var geminiResponse dto.GeminiBatchEmbeddingResponse + err = common.Unmarshal(responseBody, &geminiResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + } else { + var geminiResponse dto.GeminiEmbeddingResponse + err = common.Unmarshal(responseBody, &geminiResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + } + + service.IOCopyBytesGracefully(c, resp, responseBody) + + return usage, nil +} + +func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + helper.SetEventStreamHeaders(c) + + return geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool { + // 直接发送 GeminiChatResponse 响应 + err := helper.StringData(c, data) + if err != nil { + logger.LogError(c, err.Error()) + } + info.SendResponseCount++ + return true + }) +} diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go new file mode 100644 index 0000000000000000000000000000000000000000..9da33b3084b5ed0f6bfe485726d28e5323852042 --- /dev/null +++ b/relay/channel/gemini/relay-gemini.go @@ -0,0 +1,1344 @@ +package gemini + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "unicode/utf8" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +// https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference?hl=zh-cn#blob +var geminiSupportedMimeTypes = map[string]bool{ + "application/pdf": true, + "audio/mpeg": true, + "audio/mp3": true, + "audio/wav": true, + "image/png": true, + "image/jpeg": true, + "image/webp": true, + "text/plain": true, + "video/mov": true, + "video/mpeg": true, + "video/mp4": true, + "video/mpg": true, + "video/avi": true, + "video/wmv": true, + "video/mpegps": true, + "video/flv": true, +} + +const thoughtSignatureBypassValue = "context_engineering_is_the_way_to_go" + +// Gemini 允许的思考预算范围 +const ( + pro25MinBudget = 128 + pro25MaxBudget = 32768 + flash25MaxBudget = 24576 + flash25LiteMinBudget = 512 + flash25LiteMaxBudget = 24576 +) + +func isNew25ProModel(modelName string) bool { + return strings.HasPrefix(modelName, "gemini-2.5-pro") && + !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") && + !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25") +} + +func is25FlashLiteModel(modelName string) bool { + return strings.HasPrefix(modelName, "gemini-2.5-flash-lite") +} + +// clampThinkingBudget 根据模型名称将预算限制在允许的范围内 +func clampThinkingBudget(modelName string, budget int) int { + isNew25Pro := isNew25ProModel(modelName) + is25FlashLite := is25FlashLiteModel(modelName) + + if is25FlashLite { + if budget < flash25LiteMinBudget { + return flash25LiteMinBudget + } + if budget > flash25LiteMaxBudget { + return flash25LiteMaxBudget + } + } else if isNew25Pro { + if budget < pro25MinBudget { + return pro25MinBudget + } + if budget > pro25MaxBudget { + return pro25MaxBudget + } + } else { // 其他模型 + if budget < 0 { + return 0 + } + if budget > flash25MaxBudget { + return flash25MaxBudget + } + } + return budget +} + +// "effort": "high" - Allocates a large portion of tokens for reasoning (approximately 80% of max_tokens) +// "effort": "medium" - Allocates a moderate portion of tokens (approximately 50% of max_tokens) +// "effort": "low" - Allocates a smaller portion of tokens (approximately 20% of max_tokens) +func clampThinkingBudgetByEffort(modelName string, effort string) int { + isNew25Pro := isNew25ProModel(modelName) + is25FlashLite := is25FlashLiteModel(modelName) + + maxBudget := 0 + if is25FlashLite { + maxBudget = flash25LiteMaxBudget + } + if isNew25Pro { + maxBudget = pro25MaxBudget + } else { + maxBudget = flash25MaxBudget + } + switch effort { + case "high": + maxBudget = maxBudget * 80 / 100 + case "medium": + maxBudget = maxBudget * 50 / 100 + case "low": + maxBudget = maxBudget * 20 / 100 + } + return clampThinkingBudget(modelName, maxBudget) +} + +func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) { + if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { + modelName := info.UpstreamModelName + isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") && + !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") && + !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25") + + if strings.Contains(modelName, "-thinking-") { + parts := strings.SplitN(modelName, "-thinking-", 2) + if len(parts) == 2 && parts[1] != "" { + if budgetTokens, err := strconv.Atoi(parts[1]); err == nil { + clampedBudget := clampThinkingBudget(modelName, budgetTokens) + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ + ThinkingBudget: common.GetPointer(clampedBudget), + IncludeThoughts: true, + } + } + } + } else if strings.HasSuffix(modelName, "-thinking") { + unsupportedModels := []string{ + "gemini-2.5-pro-preview-05-06", + "gemini-2.5-pro-preview-03-25", + } + isUnsupported := false + for _, unsupportedModel := range unsupportedModels { + if strings.HasPrefix(modelName, unsupportedModel) { + isUnsupported = true + break + } + } + + if isUnsupported { + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ + IncludeThoughts: true, + } + } else { + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ + IncludeThoughts: true, + } + if geminiRequest.GenerationConfig.MaxOutputTokens > 0 { + budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens) + clampedBudget := clampThinkingBudget(modelName, int(budgetTokens)) + geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampedBudget) + } else { + if len(oaiRequest) > 0 { + // 如果有reasoningEffort参数,则根据其值设置思考预算 + geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampThinkingBudgetByEffort(modelName, oaiRequest[0].ReasoningEffort)) + } + } + } + } else if strings.HasSuffix(modelName, "-nothinking") { + if !isNew25Pro { + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ + ThinkingBudget: common.GetPointer(0), + } + } + } + } +} + +// Setting safety to the lowest possible values since Gemini is already powerless enough +func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo, base64Data ...*relaycommon.Base64Data) (*dto.GeminiChatRequest, error) { + + geminiRequest := dto.GeminiChatRequest{ + Contents: make([]dto.GeminiChatContent, 0, len(textRequest.Messages)), + GenerationConfig: dto.GeminiChatGenerationConfig{ + Temperature: textRequest.Temperature, + TopP: textRequest.TopP, + MaxOutputTokens: textRequest.GetMaxTokens(), + Seed: int64(textRequest.Seed), + }, + } + + attachThoughtSignature := (info.ChannelType == constant.ChannelTypeGemini || + info.ChannelType == constant.ChannelTypeVertexAi) && + model_setting.GetGeminiSettings().FunctionCallThoughtSignatureEnabled + + if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) { + geminiRequest.GenerationConfig.ResponseModalities = []string{ + "TEXT", + "IMAGE", + } + } + + adaptorWithExtraBody := false + + if len(textRequest.ExtraBody) > 0 { + if !strings.HasSuffix(info.UpstreamModelName, "-nothinking") { + var extraBody map[string]interface{} + if err := common.Unmarshal(textRequest.ExtraBody, &extraBody); err != nil { + return nil, fmt.Errorf("invalid extra body: %w", err) + } + // eg. {"google":{"thinking_config":{"thinking_budget":5324,"include_thoughts":true}}} + if googleBody, ok := extraBody["google"].(map[string]interface{}); ok { + adaptorWithExtraBody = true + // check error param name like thinkingConfig, should be thinking_config + if _, hasErrorParam := googleBody["thinkingConfig"]; hasErrorParam { + return nil, errors.New("extra_body.google.thinkingConfig is not supported, use extra_body.google.thinking_config instead") + } + + if thinkingConfig, ok := googleBody["thinking_config"].(map[string]interface{}); ok { + // check error param name like thinkingBudget, should be thinking_budget + if _, hasErrorParam := thinkingConfig["thinkingBudget"]; hasErrorParam { + return nil, errors.New("extra_body.google.thinking_config.thinkingBudget is not supported, use extra_body.google.thinking_config.thinking_budget instead") + } + if budget, ok := thinkingConfig["thinking_budget"].(float64); ok { + budgetInt := int(budget) + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ + ThinkingBudget: common.GetPointer(budgetInt), + IncludeThoughts: true, + } + } else { + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ + IncludeThoughts: true, + } + } + } + + if generationConfig, ok := googleBody["generationConfig"].(map[string]any); ok { + generationConfigBytes, err := json.Marshal(generationConfig) + if err != nil { + return nil, fmt.Errorf("failed to marshal generationConfig: %w", err) + } + if err := json.Unmarshal(generationConfigBytes, &geminiRequest.GenerationConfig); err != nil { + return nil, fmt.Errorf("failed to unmarshal generationConfig: %w", err) + } + } + } + } + } + + if !adaptorWithExtraBody { + ThinkingAdaptor(&geminiRequest, info, textRequest) + } + + safetySettings := make([]dto.GeminiChatSafetySettings, 0, len(SafetySettingList)) + for _, category := range SafetySettingList { + safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{ + Category: category, + Threshold: model_setting.GetGeminiSafetySetting(category), + }) + } + geminiRequest.SafetySettings = safetySettings + + // openaiContent.FuncToToolCalls() + if textRequest.Tools != nil { + functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools)) + googleSearch := false + codeExecution := false + urlContext := false + for _, tool := range textRequest.Tools { + if tool.Function.Name == "googleSearch" { + googleSearch = true + continue + } + if tool.Function.Name == "codeExecution" { + codeExecution = true + continue + } + if tool.Function.Name == "urlContext" { + urlContext = true + continue + } + if tool.Function.Parameters != nil { + + params, ok := tool.Function.Parameters.(map[string]interface{}) + if ok { + if props, hasProps := params["properties"].(map[string]interface{}); hasProps { + if len(props) == 0 { + tool.Function.Parameters = nil + } + } + } + } + // Clean the parameters before appending + cleanedParams := cleanFunctionParameters(tool.Function.Parameters) + tool.Function.Parameters = cleanedParams + functions = append(functions, tool.Function) + } + geminiTools := geminiRequest.GetTools() + if codeExecution { + geminiTools = append(geminiTools, dto.GeminiChatTool{ + CodeExecution: make(map[string]string), + }) + } + if googleSearch { + geminiTools = append(geminiTools, dto.GeminiChatTool{ + GoogleSearch: make(map[string]string), + }) + } + if urlContext { + geminiTools = append(geminiTools, dto.GeminiChatTool{ + URLContext: make(map[string]string), + }) + } + if len(functions) > 0 { + geminiTools = append(geminiTools, dto.GeminiChatTool{ + FunctionDeclarations: functions, + }) + } + geminiRequest.SetTools(geminiTools) + } + + if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") { + geminiRequest.GenerationConfig.ResponseMimeType = "application/json" + + if len(textRequest.ResponseFormat.JsonSchema) > 0 { + // 先将json.RawMessage解析 + var jsonSchema dto.FormatJsonSchema + if err := common.Unmarshal(textRequest.ResponseFormat.JsonSchema, &jsonSchema); err == nil { + cleanedSchema := removeAdditionalPropertiesWithDepth(jsonSchema.Schema, 0) + geminiRequest.GenerationConfig.ResponseSchema = cleanedSchema + } + } + } + tool_call_ids := make(map[string]string) + var system_content []string + //shouldAddDummyModelMessage := false + for _, message := range textRequest.Messages { + if message.Role == "system" { + system_content = append(system_content, message.StringContent()) + continue + } else if message.Role == "tool" || message.Role == "function" { + if len(geminiRequest.Contents) == 0 || geminiRequest.Contents[len(geminiRequest.Contents)-1].Role == "model" { + geminiRequest.Contents = append(geminiRequest.Contents, dto.GeminiChatContent{ + Role: "user", + }) + } + var parts = &geminiRequest.Contents[len(geminiRequest.Contents)-1].Parts + name := "" + if message.Name != nil { + name = *message.Name + } else if val, exists := tool_call_ids[message.ToolCallId]; exists { + name = val + } + var contentMap map[string]interface{} + contentStr := message.StringContent() + + // 1. 尝试解析为 JSON 对象 + if err := json.Unmarshal([]byte(contentStr), &contentMap); err != nil { + // 2. 如果失败,尝试解析为 JSON 数组 + var contentSlice []interface{} + if err := json.Unmarshal([]byte(contentStr), &contentSlice); err == nil { + // 如果是数组,包装成对象 + contentMap = map[string]interface{}{"result": contentSlice} + } else { + // 3. 如果再次失败,作为纯文本处理 + contentMap = map[string]interface{}{"content": contentStr} + } + } + + functionResp := &dto.GeminiFunctionResponse{ + Name: name, + Response: contentMap, + } + + *parts = append(*parts, dto.GeminiPart{ + FunctionResponse: functionResp, + }) + continue + } + var parts []dto.GeminiPart + content := dto.GeminiChatContent{ + Role: message.Role, + } + shouldAttachThoughtSignature := attachThoughtSignature && (message.Role == "assistant" || message.Role == "model") + signatureAttached := false + // isToolCall := false + if message.ToolCalls != nil { + // message.Role = "model" + // isToolCall = true + for _, call := range message.ParseToolCalls() { + args := map[string]interface{}{} + if call.Function.Arguments != "" { + if json.Unmarshal([]byte(call.Function.Arguments), &args) != nil { + return nil, fmt.Errorf("invalid arguments for function %s, args: %s", call.Function.Name, call.Function.Arguments) + } + } + toolCall := dto.GeminiPart{ + FunctionCall: &dto.FunctionCall{ + FunctionName: call.Function.Name, + Arguments: args, + }, + } + if shouldAttachThoughtSignature && !signatureAttached && hasFunctionCallContent(toolCall.FunctionCall) && len(toolCall.ThoughtSignature) == 0 { + toolCall.ThoughtSignature = json.RawMessage(strconv.Quote(thoughtSignatureBypassValue)) + signatureAttached = true + } + parts = append(parts, toolCall) + tool_call_ids[call.ID] = call.Function.Name + } + } + + openaiContent := message.ParseContent() + imageNum := 0 + for _, part := range openaiContent { + if part.Type == dto.ContentTypeText { + if part.Text == "" { + continue + } + parts = append(parts, dto.GeminiPart{ + Text: part.Text, + }) + } else if part.Type == dto.ContentTypeImageURL { + imageNum += 1 + + if constant.GeminiVisionMaxImageNum != -1 && imageNum > constant.GeminiVisionMaxImageNum { + return nil, fmt.Errorf("too many images in the message, max allowed is %d", constant.GeminiVisionMaxImageNum) + } + // 判断是否是url + if strings.HasPrefix(part.GetImageMedia().Url, "http") { + // 是url,获取文件的类型和base64编码的数据 + fileData, err := service.GetFileBase64FromUrl(c, part.GetImageMedia().Url, "formatting image for Gemini") + if err != nil { + return nil, fmt.Errorf("get file base64 from url '%s' failed: %w", part.GetImageMedia().Url, err) + } + + // 校验 MimeType 是否在 Gemini 支持的白名单中 + if _, ok := geminiSupportedMimeTypes[strings.ToLower(fileData.MimeType)]; !ok { + url := part.GetImageMedia().Url + return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", fileData.MimeType, url, getSupportedMimeTypesList()) + } + + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{ + MimeType: fileData.MimeType, // 使用原始的 MimeType,因为大小写可能对API有意义 + Data: fileData.Base64Data, + }, + }) + } else { + format, base64String, err := service.DecodeBase64FileData(part.GetImageMedia().Url) + if err != nil { + return nil, fmt.Errorf("decode base64 image data failed: %s", err.Error()) + } + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{ + MimeType: format, + Data: base64String, + }, + }) + } + } else if part.Type == dto.ContentTypeFile { + file := part.GetFile() + if file.FileId != "" { + return nil, fmt.Errorf("only base64 file is supported in gemini") + } + format, base64String, err := service.DecodeBase64FileData(file.FileData) + if err != nil { + return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error()) + } + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{ + MimeType: format, + Data: base64String, + }, + }) + } else if part.Type == dto.ContentTypeInputAudio { + if part.GetInputAudio().Data == "" { + return nil, fmt.Errorf("only base64 audio is supported in gemini") + } + base64String, err := service.DecodeBase64AudioData(part.GetInputAudio().Data) + if err != nil { + return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error()) + } + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{ + MimeType: "audio/" + part.GetInputAudio().Format, + Data: base64String, + }, + }) + } + } + + // 如果需要附加签名但还没有附加(没有 tool_calls 或 tool_calls 为空), + // 则在第一个文本 part 上附加 thoughtSignature + if shouldAttachThoughtSignature && !signatureAttached && len(parts) > 0 { + for i := range parts { + if parts[i].Text != "" { + parts[i].ThoughtSignature = json.RawMessage(strconv.Quote(thoughtSignatureBypassValue)) + break + } + } + } + + content.Parts = parts + + // there's no assistant role in gemini and API shall vomit if Role is not user or model + if content.Role == "assistant" { + content.Role = "model" + } + if len(content.Parts) > 0 { + geminiRequest.Contents = append(geminiRequest.Contents, content) + } + } + + if len(system_content) > 0 { + geminiRequest.SystemInstructions = &dto.GeminiChatContent{ + Parts: []dto.GeminiPart{ + { + Text: strings.Join(system_content, "\n"), + }, + }, + } + } + + return &geminiRequest, nil +} + +func hasFunctionCallContent(call *dto.FunctionCall) bool { + if call == nil { + return false + } + if strings.TrimSpace(call.FunctionName) != "" { + return true + } + + switch v := call.Arguments.(type) { + case nil: + return false + case string: + return strings.TrimSpace(v) != "" + case map[string]interface{}: + return len(v) > 0 + case []interface{}: + return len(v) > 0 + default: + return true + } +} + +// Helper function to get a list of supported MIME types for error messages +func getSupportedMimeTypesList() []string { + keys := make([]string, 0, len(geminiSupportedMimeTypes)) + for k := range geminiSupportedMimeTypes { + keys = append(keys, k) + } + return keys +} + +// cleanFunctionParameters recursively removes unsupported fields from Gemini function parameters. +func cleanFunctionParameters(params interface{}) interface{} { + if params == nil { + return nil + } + + switch v := params.(type) { + case map[string]interface{}: + // Create a copy to avoid modifying the original + cleanedMap := make(map[string]interface{}) + for k, val := range v { + cleanedMap[k] = val + } + + // Remove unsupported root-level fields + delete(cleanedMap, "default") + delete(cleanedMap, "exclusiveMaximum") + delete(cleanedMap, "exclusiveMinimum") + delete(cleanedMap, "$schema") + delete(cleanedMap, "additionalProperties") + + // Check and clean 'format' for string types + if propType, typeExists := cleanedMap["type"].(string); typeExists && propType == "string" { + if formatValue, formatExists := cleanedMap["format"].(string); formatExists { + if formatValue != "enum" && formatValue != "date-time" { + delete(cleanedMap, "format") + } + } + } + + // Clean properties + if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil { + cleanedProps := make(map[string]interface{}) + for propName, propValue := range props { + cleanedProps[propName] = cleanFunctionParameters(propValue) + } + cleanedMap["properties"] = cleanedProps + } + + // Recursively clean items in arrays + if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil { + cleanedMap["items"] = cleanFunctionParameters(items) + } + // Also handle items if it's an array of schemas + if itemsArray, ok := cleanedMap["items"].([]interface{}); ok { + cleanedItemsArray := make([]interface{}, len(itemsArray)) + for i, item := range itemsArray { + cleanedItemsArray[i] = cleanFunctionParameters(item) + } + cleanedMap["items"] = cleanedItemsArray + } + + // Recursively clean other schema composition keywords + for _, field := range []string{"allOf", "anyOf", "oneOf"} { + if nested, ok := cleanedMap[field].([]interface{}); ok { + cleanedNested := make([]interface{}, len(nested)) + for i, item := range nested { + cleanedNested[i] = cleanFunctionParameters(item) + } + cleanedMap[field] = cleanedNested + } + } + + // Recursively clean patternProperties + if patternProps, ok := cleanedMap["patternProperties"].(map[string]interface{}); ok { + cleanedPatternProps := make(map[string]interface{}) + for pattern, schema := range patternProps { + cleanedPatternProps[pattern] = cleanFunctionParameters(schema) + } + cleanedMap["patternProperties"] = cleanedPatternProps + } + + // Recursively clean definitions + if definitions, ok := cleanedMap["definitions"].(map[string]interface{}); ok { + cleanedDefinitions := make(map[string]interface{}) + for defName, defSchema := range definitions { + cleanedDefinitions[defName] = cleanFunctionParameters(defSchema) + } + cleanedMap["definitions"] = cleanedDefinitions + } + + // Recursively clean $defs (newer JSON Schema draft) + if defs, ok := cleanedMap["$defs"].(map[string]interface{}); ok { + cleanedDefs := make(map[string]interface{}) + for defName, defSchema := range defs { + cleanedDefs[defName] = cleanFunctionParameters(defSchema) + } + cleanedMap["$defs"] = cleanedDefs + } + + // Clean conditional keywords + for _, field := range []string{"if", "then", "else", "not"} { + if nested, ok := cleanedMap[field]; ok { + cleanedMap[field] = cleanFunctionParameters(nested) + } + } + + return cleanedMap + + case []interface{}: + // Handle arrays of schemas + cleanedArray := make([]interface{}, len(v)) + for i, item := range v { + cleanedArray[i] = cleanFunctionParameters(item) + } + return cleanedArray + + default: + // Not a map or array, return as is (e.g., could be a primitive) + return params + } +} + +func removeAdditionalPropertiesWithDepth(schema interface{}, depth int) interface{} { + if depth >= 5 { + return schema + } + + v, ok := schema.(map[string]interface{}) + if !ok || len(v) == 0 { + return schema + } + // 删除所有的title字段 + delete(v, "title") + delete(v, "$schema") + // 如果type不为object和array,则直接返回 + if typeVal, exists := v["type"]; !exists || (typeVal != "object" && typeVal != "array") { + return schema + } + switch v["type"] { + case "object": + delete(v, "additionalProperties") + // 处理 properties + if properties, ok := v["properties"].(map[string]interface{}); ok { + for key, value := range properties { + properties[key] = removeAdditionalPropertiesWithDepth(value, depth+1) + } + } + for _, field := range []string{"allOf", "anyOf", "oneOf"} { + if nested, ok := v[field].([]interface{}); ok { + for i, item := range nested { + nested[i] = removeAdditionalPropertiesWithDepth(item, depth+1) + } + } + } + case "array": + if items, ok := v["items"].(map[string]interface{}); ok { + v["items"] = removeAdditionalPropertiesWithDepth(items, depth+1) + } + } + + return v +} + +func unescapeString(s string) (string, error) { + var result []rune + escaped := false + i := 0 + + for i < len(s) { + r, size := utf8.DecodeRuneInString(s[i:]) // 正确解码UTF-8字符 + if r == utf8.RuneError { + return "", fmt.Errorf("invalid UTF-8 encoding") + } + + if escaped { + // 如果是转义符后的字符,检查其类型 + switch r { + case '"': + result = append(result, '"') + case '\\': + result = append(result, '\\') + case '/': + result = append(result, '/') + case 'b': + result = append(result, '\b') + case 'f': + result = append(result, '\f') + case 'n': + result = append(result, '\n') + case 'r': + result = append(result, '\r') + case 't': + result = append(result, '\t') + case '\'': + result = append(result, '\'') + default: + // 如果遇到一个非法的转义字符,直接按原样输出 + result = append(result, '\\', r) + } + escaped = false + } else { + if r == '\\' { + escaped = true // 记录反斜杠作为转义符 + } else { + result = append(result, r) + } + } + i += size // 移动到下一个字符 + } + + return string(result), nil +} +func unescapeMapOrSlice(data interface{}) interface{} { + switch v := data.(type) { + case map[string]interface{}: + for k, val := range v { + v[k] = unescapeMapOrSlice(val) + } + case []interface{}: + for i, val := range v { + v[i] = unescapeMapOrSlice(val) + } + case string: + if unescaped, err := unescapeString(v); err != nil { + return v + } else { + return unescaped + } + } + return data +} + +func getResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse { + var argsBytes []byte + var err error + if result, ok := item.FunctionCall.Arguments.(map[string]interface{}); ok { + argsBytes, err = json.Marshal(unescapeMapOrSlice(result)) + } else { + argsBytes, err = json.Marshal(item.FunctionCall.Arguments) + } + + if err != nil { + return nil + } + return &dto.ToolCallResponse{ + ID: fmt.Sprintf("call_%s", common.GetUUID()), + Type: "function", + Function: dto.FunctionResponse{ + Arguments: string(argsBytes), + Name: item.FunctionCall.FunctionName, + }, + } +} + +func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse) *dto.OpenAITextResponse { + fullTextResponse := dto.OpenAITextResponse{ + Id: helper.GetResponseID(c), + Object: "chat.completion", + Created: common.GetTimestamp(), + Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), + } + isToolCall := false + for _, candidate := range response.Candidates { + choice := dto.OpenAITextResponseChoice{ + Index: int(candidate.Index), + Message: dto.Message{ + Role: "assistant", + Content: "", + }, + FinishReason: constant.FinishReasonStop, + } + if len(candidate.Content.Parts) > 0 { + var texts []string + var toolCalls []dto.ToolCallResponse + for _, part := range candidate.Content.Parts { + if part.InlineData != nil { + // 媒体内容 + if strings.HasPrefix(part.InlineData.MimeType, "image") { + imgText := "![image](data:" + part.InlineData.MimeType + ";base64," + part.InlineData.Data + ")" + texts = append(texts, imgText) + } else { + // 其他媒体类型,直接显示链接 + texts = append(texts, fmt.Sprintf("[media](data:%s;base64,%s)", part.InlineData.MimeType, part.InlineData.Data)) + } + } else if part.FunctionCall != nil { + choice.FinishReason = constant.FinishReasonToolCalls + if call := getResponseToolCall(&part); call != nil { + toolCalls = append(toolCalls, *call) + } + } else if part.Thought { + choice.Message.ReasoningContent = part.Text + } else { + if part.ExecutableCode != nil { + texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```") + } else if part.CodeExecutionResult != nil { + texts = append(texts, "```output\n"+part.CodeExecutionResult.Output+"\n```") + } else { + // 过滤掉空行 + if part.Text != "\n" { + texts = append(texts, part.Text) + } + } + } + } + if len(toolCalls) > 0 { + choice.Message.SetToolCalls(toolCalls) + isToolCall = true + } + choice.Message.SetStringContent(strings.Join(texts, "\n")) + + } + if candidate.FinishReason != nil { + switch *candidate.FinishReason { + case "STOP": + choice.FinishReason = constant.FinishReasonStop + case "MAX_TOKENS": + choice.FinishReason = constant.FinishReasonLength + default: + choice.FinishReason = constant.FinishReasonContentFilter + } + } + if isToolCall { + choice.FinishReason = constant.FinishReasonToolCalls + } + + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + } + return &fullTextResponse +} + +func streamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) { + choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates)) + isStop := false + for _, candidate := range geminiResponse.Candidates { + if candidate.FinishReason != nil && *candidate.FinishReason == "STOP" { + isStop = true + candidate.FinishReason = nil + } + choice := dto.ChatCompletionsStreamResponseChoice{ + Index: int(candidate.Index), + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + //Role: "assistant", + }, + } + var texts []string + isTools := false + isThought := false + if candidate.FinishReason != nil { + // p := GeminiConvertFinishReason(*candidate.FinishReason) + switch *candidate.FinishReason { + case "STOP": + choice.FinishReason = &constant.FinishReasonStop + case "MAX_TOKENS": + choice.FinishReason = &constant.FinishReasonLength + default: + choice.FinishReason = &constant.FinishReasonContentFilter + } + } + for _, part := range candidate.Content.Parts { + if part.InlineData != nil { + if strings.HasPrefix(part.InlineData.MimeType, "image") { + imgText := "![image](data:" + part.InlineData.MimeType + ";base64," + part.InlineData.Data + ")" + texts = append(texts, imgText) + } + } else if part.FunctionCall != nil { + isTools = true + if call := getResponseToolCall(&part); call != nil { + call.SetIndex(len(choice.Delta.ToolCalls)) + choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, *call) + } + + } else if part.Thought { + isThought = true + texts = append(texts, part.Text) + } else { + if part.ExecutableCode != nil { + texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```\n") + } else if part.CodeExecutionResult != nil { + texts = append(texts, "```output\n"+part.CodeExecutionResult.Output+"\n```\n") + } else { + if part.Text != "\n" { + texts = append(texts, part.Text) + } + } + } + } + if isThought { + choice.Delta.SetReasoningContent(strings.Join(texts, "\n")) + } else { + choice.Delta.SetContentString(strings.Join(texts, "\n")) + } + if isTools { + choice.FinishReason = &constant.FinishReasonToolCalls + } + choices = append(choices, choice) + } + + var response dto.ChatCompletionsStreamResponse + response.Object = "chat.completion.chunk" + response.Choices = choices + return &response, isStop +} + +func handleStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error { + streamData, err := common.Marshal(resp) + if err != nil { + return fmt.Errorf("failed to marshal stream response: %w", err) + } + err = openai.HandleStreamFormat(c, info, string(streamData), info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent) + if err != nil { + return fmt.Errorf("failed to handle stream format: %w", err) + } + return nil +} + +func handleFinalStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error { + streamData, err := common.Marshal(resp) + if err != nil { + return fmt.Errorf("failed to marshal stream response: %w", err) + } + openai.HandleFinalResponse(c, info, string(streamData), resp.Id, resp.Created, resp.Model, resp.GetSystemFingerprint(), resp.Usage, false) + return nil +} + +func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response, callback func(data string, geminiResponse *dto.GeminiChatResponse) bool) (*dto.Usage, *types.NewAPIError) { + var usage = &dto.Usage{} + var imageCount int + responseText := strings.Builder{} + + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + var geminiResponse dto.GeminiChatResponse + err := common.UnmarshalJsonStr(data, &geminiResponse) + if err != nil { + logger.LogError(c, "error unmarshalling stream response: "+err.Error()) + return false + } + + // 统计图片数量 + for _, candidate := range geminiResponse.Candidates { + for _, part := range candidate.Content.Parts { + if part.InlineData != nil && part.InlineData.MimeType != "" { + imageCount++ + } + if part.Text != "" { + responseText.WriteString(part.Text) + } + } + } + + // 更新使用量统计 + if geminiResponse.UsageMetadata.TotalTokenCount != 0 { + usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount + usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount + usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount + usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } + } + + return callback(data, &geminiResponse) + }) + + if imageCount != 0 { + if usage.CompletionTokens == 0 { + usage.CompletionTokens = imageCount * 1400 + } + } + + usage.PromptTokensDetails.TextTokens = usage.PromptTokens + if usage.TotalTokens > 0 { + usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens + } + + if usage.CompletionTokens <= 0 { + str := responseText.String() + if len(str) > 0 { + usage = service.ResponseText2Usage(c, responseText.String(), info.UpstreamModelName, info.PromptTokens) + } else { + usage = &dto.Usage{} + } + } + + return usage, nil +} + +func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + id := helper.GetResponseID(c) + createAt := common.GetTimestamp() + finishReason := constant.FinishReasonStop + + usage, err := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool { + response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse) + + response.Id = id + response.Created = createAt + response.Model = info.UpstreamModelName + + logger.LogDebug(c, fmt.Sprintf("info.SendResponseCount = %d", info.SendResponseCount)) + if info.SendResponseCount == 0 { + // send first response + emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil) + if response.IsToolCall() { + if len(emptyResponse.Choices) > 0 && len(response.Choices) > 0 { + toolCalls := response.Choices[0].Delta.ToolCalls + copiedToolCalls := make([]dto.ToolCallResponse, len(toolCalls)) + for idx := range toolCalls { + copiedToolCalls[idx] = toolCalls[idx] + copiedToolCalls[idx].Function.Arguments = "" + } + emptyResponse.Choices[0].Delta.ToolCalls = copiedToolCalls + } + finishReason = constant.FinishReasonToolCalls + err := handleStream(c, info, emptyResponse) + if err != nil { + logger.LogError(c, err.Error()) + } + + response.ClearToolCalls() + if response.IsFinished() { + response.Choices[0].FinishReason = nil + } + } else { + err := handleStream(c, info, emptyResponse) + if err != nil { + logger.LogError(c, err.Error()) + } + } + } + + err := handleStream(c, info, response) + if err != nil { + logger.LogError(c, err.Error()) + } + if isStop { + _ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason)) + } + return true + }) + + if err != nil { + return usage, err + } + + response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage) + handleErr := handleFinalStream(c, info, response) + if handleErr != nil { + common.SysLog("send final response failed: " + handleErr.Error()) + } + return usage, nil +} + +func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + if common.DebugEnabled { + println(string(responseBody)) + } + var geminiResponse dto.GeminiChatResponse + err = common.Unmarshal(responseBody, &geminiResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if len(geminiResponse.Candidates) == 0 { + //return nil, types.NewOpenAIError(errors.New("no candidates returned"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + //if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil { + // return nil, types.NewOpenAIError(errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason), types.ErrorCodePromptBlocked, http.StatusBadRequest) + //} else { + // return nil, types.NewOpenAIError(errors.New("empty response from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError) + //} + } + fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse) + fullTextResponse.Model = info.UpstreamModelName + usage := dto.Usage{ + PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount, + CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount, + TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount, + } + + usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount + usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens + + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } + + fullTextResponse.Usage = usage + + switch info.RelayFormat { + case types.RelayFormatOpenAI: + responseBody, err = common.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + case types.RelayFormatClaude: + claudeResp := service.ResponseOpenAI2Claude(fullTextResponse, info) + claudeRespStr, err := common.Marshal(claudeResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + responseBody = claudeRespStr + case types.RelayFormatGemini: + break + } + + service.IOCopyBytesGracefully(c, resp, responseBody) + + return &usage, nil +} + +func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer service.CloseResponseBodyGracefully(resp) + + responseBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + var geminiResponse dto.GeminiBatchEmbeddingResponse + if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { + return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + // convert to openai format response + openAIResponse := dto.OpenAIEmbeddingResponse{ + Object: "list", + Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(geminiResponse.Embeddings)), + Model: info.UpstreamModelName, + } + + for i, embedding := range geminiResponse.Embeddings { + openAIResponse.Data = append(openAIResponse.Data, dto.OpenAIEmbeddingResponseItem{ + Object: "embedding", + Embedding: embedding.Values, + Index: i, + }) + } + + // calculate usage + // https://ai.google.dev/gemini-api/docs/pricing?hl=zh-cn#text-embedding-004 + // Google has not yet clarified how embedding models will be billed + // refer to openai billing method to use input tokens billing + // https://platform.openai.com/docs/guides/embeddings#what-are-embeddings + usage := &dto.Usage{ + PromptTokens: info.PromptTokens, + CompletionTokens: 0, + TotalTokens: info.PromptTokens, + } + openAIResponse.Usage = *usage + + jsonResponse, jsonErr := common.Marshal(openAIResponse) + if jsonErr != nil { + return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + service.IOCopyBytesGracefully(c, resp, jsonResponse) + return usage, nil +} + +func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + _ = resp.Body.Close() + + var geminiResponse dto.GeminiImageResponse + if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { + return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if len(geminiResponse.Predictions) == 0 { + return nil, types.NewOpenAIError(errors.New("no images generated"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + // convert to openai format response + openAIResponse := dto.ImageResponse{ + Created: common.GetTimestamp(), + Data: make([]dto.ImageData, 0, len(geminiResponse.Predictions)), + } + + for _, prediction := range geminiResponse.Predictions { + if prediction.RaiFilteredReason != "" { + continue // skip filtered image + } + openAIResponse.Data = append(openAIResponse.Data, dto.ImageData{ + B64Json: prediction.BytesBase64Encoded, + }) + } + + jsonResponse, jsonErr := json.Marshal(openAIResponse) + if jsonErr != nil { + return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody) + } + + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + + // https://github.com/google-gemini/cookbook/blob/719a27d752aac33f39de18a8d3cb42a70874917e/quickstarts/Counting_Tokens.ipynb + // each image has fixed 258 tokens + const imageTokens = 258 + generatedImages := len(openAIResponse.Data) + + usage := &dto.Usage{ + PromptTokens: imageTokens * generatedImages, // each generated image has fixed 258 tokens + CompletionTokens: 0, // image generation does not calculate completion tokens + TotalTokens: imageTokens * generatedImages, + } + + return usage, nil +} + +func convertToOaiImageResponse(geminiResponse *dto.GeminiChatResponse) (*dto.ImageResponse, error) { + openAIResponse := &dto.ImageResponse{ + Created: common.GetTimestamp(), + Data: make([]dto.ImageData, 0), + } + + // extract images from candidates' inlineData + for _, candidate := range geminiResponse.Candidates { + for _, part := range candidate.Content.Parts { + if part.InlineData != nil && strings.HasPrefix(part.InlineData.MimeType, "image") { + openAIResponse.Data = append(openAIResponse.Data, dto.ImageData{ + B64Json: part.InlineData.Data, + }) + } + } + } + + if len(openAIResponse.Data) == 0 { + return nil, errors.New("no images found in response") + } + + return openAIResponse, nil +} + +func ChatImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + + if common.DebugEnabled { + println("ChatImageHandler response:", string(responseBody)) + } + + var geminiResponse dto.GeminiChatResponse + if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { + return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if len(geminiResponse.Candidates) == 0 { + return nil, types.NewOpenAIError(errors.New("no images generated"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + openAIResponse, err := convertToOaiImageResponse(&geminiResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + jsonResponse, jsonErr := json.Marshal(openAIResponse) + if jsonErr != nil { + return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody) + } + + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + + usage := &dto.Usage{ + PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount, + CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount, + TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount, + } + + return usage, nil +} diff --git a/relay/channel/jimeng/adaptor.go b/relay/channel/jimeng/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..1938ac1bec184499f131439a2585635fbab04b0f --- /dev/null +++ b/relay/channel/jimeng/adaptor.go @@ -0,0 +1,143 @@ +package jimeng + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/?Action=CVProcess&Version=2022-08-31", info.ChannelBaseUrl), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) error { + return errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +type LogoInfo struct { + AddLogo bool `json:"add_logo,omitempty"` + Position int `json:"position,omitempty"` + Language int `json:"language,omitempty"` + Opacity float64 `json:"opacity,omitempty"` + LogoTextContent string `json:"logo_text_content,omitempty"` +} + +type imageRequestPayload struct { + ReqKey string `json:"req_key"` // Service identifier, fixed value: jimeng_high_aes_general_v21_L + Prompt string `json:"prompt"` // Prompt for image generation, supports both Chinese and English + Seed int64 `json:"seed,omitempty"` // Random seed, default -1 (random) + Width int `json:"width,omitempty"` // Image width, default 512, range [256, 768] + Height int `json:"height,omitempty"` // Image height, default 512, range [256, 768] + UsePreLLM bool `json:"use_pre_llm,omitempty"` // Enable text expansion, default true + UseSR bool `json:"use_sr,omitempty"` // Enable super resolution, default true + ReturnURL bool `json:"return_url,omitempty"` // Whether to return image URL (valid for 24 hours) + LogoInfo LogoInfo `json:"logo_info,omitempty"` // Watermark information + ImageUrls []string `json:"image_urls,omitempty"` // Image URLs for input + BinaryData []string `json:"binary_data_base64,omitempty"` // Base64 encoded binary data +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + payload := imageRequestPayload{ + ReqKey: request.Model, + Prompt: request.Prompt, + } + if request.ResponseFormat == "" || request.ResponseFormat == "url" { + payload.ReturnURL = true // Default to returning image URLs + } + + if len(request.ExtraFields) > 0 { + if err := json.Unmarshal(request.ExtraFields, &payload); err != nil { + return nil, fmt.Errorf("failed to unmarshal extra fields: %w", err) + } + } + + return payload, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + fullRequestURL, err := a.GetRequestURL(info) + if err != nil { + return nil, fmt.Errorf("get request url failed: %w", err) + } + req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + if err != nil { + return nil, fmt.Errorf("new request failed: %w", err) + } + err = Sign(c, req, info.ApiKey) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + resp, err := channel.DoRequest(c, req, info) + if err != nil { + return nil, fmt.Errorf("do request failed: %w", err) + } + return resp, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.RelayMode == relayconstant.RelayModeImagesGenerations { + usage, err = jimengImageHandler(c, resp, info) + } else if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/jimeng/constants.go b/relay/channel/jimeng/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..0d1764e54d11c39ad983e6d62e2e6f9367f1dd18 --- /dev/null +++ b/relay/channel/jimeng/constants.go @@ -0,0 +1,9 @@ +package jimeng + +const ( + ChannelName = "jimeng" +) + +var ModelList = []string{ + "jimeng_high_aes_general_v21_L", +} diff --git a/relay/channel/jimeng/image.go b/relay/channel/jimeng/image.go new file mode 100644 index 0000000000000000000000000000000000000000..e422e061de6d3c3c5663313808c5f6dc40e9f31f --- /dev/null +++ b/relay/channel/jimeng/image.go @@ -0,0 +1,90 @@ +package jimeng + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type ImageResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + BinaryDataBase64 []string `json:"binary_data_base64"` + ImageUrls []string `json:"image_urls"` + RephraseResult string `json:"rephraser_result"` + RequestID string `json:"request_id"` + // Other fields are omitted for brevity + } `json:"data"` + RequestID string `json:"request_id"` + Status int `json:"status"` + TimeElapsed string `json:"time_elapsed"` +} + +func responseJimeng2OpenAIImage(_ *gin.Context, response *ImageResponse, info *relaycommon.RelayInfo) *dto.ImageResponse { + imageResponse := dto.ImageResponse{ + Created: info.StartTime.Unix(), + } + + for _, base64Data := range response.Data.BinaryDataBase64 { + imageResponse.Data = append(imageResponse.Data, dto.ImageData{ + B64Json: base64Data, + }) + } + for _, imageUrl := range response.Data.ImageUrls { + imageResponse.Data = append(imageResponse.Data, dto.ImageData{ + Url: imageUrl, + }) + } + + return &imageResponse +} + +// jimengImageHandler handles the Jimeng image generation response +func jimengImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) { + var jimengResponse ImageResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + + err = json.Unmarshal(responseBody, &jimengResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + // Check if the response indicates an error + if jimengResponse.Code != 10000 { + return nil, types.WithOpenAIError(types.OpenAIError{ + Message: jimengResponse.Message, + Type: "jimeng_error", + Param: "", + Code: fmt.Sprintf("%d", jimengResponse.Code), + }, resp.StatusCode) + } + + // Convert Jimeng response to OpenAI format + fullTextResponse := responseJimeng2OpenAIImage(c, &jimengResponse, info) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + + return &dto.Usage{}, nil +} diff --git a/relay/channel/jimeng/sign.go b/relay/channel/jimeng/sign.go new file mode 100644 index 0000000000000000000000000000000000000000..7c67531e414ea7b97618e57c8d6cb5ad5c880f6b --- /dev/null +++ b/relay/channel/jimeng/sign.go @@ -0,0 +1,177 @@ +package jimeng + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/QuantumNous/new-api/logger" + "github.com/gin-gonic/gin" +) + +// SignRequestForJimeng 对即梦 API 请求进行签名,支持 http.Request 或 header+url+body 方式 +//func SignRequestForJimeng(req *http.Request, accessKey, secretKey string) error { +// var bodyBytes []byte +// var err error +// +// if req.Body != nil { +// bodyBytes, err = io.ReadAll(req.Body) +// if err != nil { +// return fmt.Errorf("read request body failed: %w", err) +// } +// _ = req.Body.Close() +// req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // rewind +// } else { +// bodyBytes = []byte{} +// } +// +// return signJimengHeaders(&req.Header, req.Method, req.URL, bodyBytes, accessKey, secretKey) +//} + +const HexPayloadHashKey = "HexPayloadHash" + +func SetPayloadHash(c *gin.Context, req any) error { + body, err := json.Marshal(req) + if err != nil { + return err + } + logger.LogInfo(c, fmt.Sprintf("SetPayloadHash body: %s", body)) + payloadHash := sha256.Sum256(body) + hexPayloadHash := hex.EncodeToString(payloadHash[:]) + c.Set(HexPayloadHashKey, hexPayloadHash) + return nil +} +func getPayloadHash(c *gin.Context) string { + return c.GetString(HexPayloadHashKey) +} + +func Sign(c *gin.Context, req *http.Request, apiKey string) error { + header := req.Header + + var bodyBytes []byte + var err error + + if req.Body != nil { + bodyBytes, err = io.ReadAll(req.Body) + if err != nil { + return err + } + _ = req.Body.Close() + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Rewind + } + + payloadHash := sha256.Sum256(bodyBytes) + hexPayloadHash := hex.EncodeToString(payloadHash[:]) + + method := c.Request.Method + u := req.URL + keyParts := strings.Split(apiKey, "|") + if len(keyParts) != 2 { + return errors.New("invalid api key format for jimeng: expected 'ak|sk'") + } + accessKey := strings.TrimSpace(keyParts[0]) + secretKey := strings.TrimSpace(keyParts[1]) + t := time.Now().UTC() + xDate := t.Format("20060102T150405Z") + shortDate := t.Format("20060102") + + host := u.Host + header.Set("Host", host) + header.Set("X-Date", xDate) + header.Set("X-Content-Sha256", hexPayloadHash) + + // Sort and encode query parameters to create canonical query string + queryParams := u.Query() + sortedKeys := make([]string, 0, len(queryParams)) + for k := range queryParams { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + var queryParts []string + for _, k := range sortedKeys { + values := queryParams[k] + sort.Strings(values) + for _, v := range values { + queryParts = append(queryParts, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(v))) + } + } + canonicalQueryString := strings.Join(queryParts, "&") + + headersToSign := map[string]string{ + "host": host, + "x-date": xDate, + "x-content-sha256": hexPayloadHash, + } + if header.Get("Content-Type") == "" { + header.Set("Content-Type", "application/json") + } + headersToSign["content-type"] = header.Get("Content-Type") + + var signedHeaderKeys []string + for k := range headersToSign { + signedHeaderKeys = append(signedHeaderKeys, k) + } + sort.Strings(signedHeaderKeys) + + var canonicalHeaders strings.Builder + for _, k := range signedHeaderKeys { + canonicalHeaders.WriteString(k) + canonicalHeaders.WriteString(":") + canonicalHeaders.WriteString(strings.TrimSpace(headersToSign[k])) + canonicalHeaders.WriteString("\n") + } + signedHeaders := strings.Join(signedHeaderKeys, ";") + + canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", + method, + u.Path, + canonicalQueryString, + canonicalHeaders.String(), + signedHeaders, + hexPayloadHash, + ) + + hashedCanonicalRequest := sha256.Sum256([]byte(canonicalRequest)) + hexHashedCanonicalRequest := hex.EncodeToString(hashedCanonicalRequest[:]) + + region := "cn-north-1" + serviceName := "cv" + credentialScope := fmt.Sprintf("%s/%s/%s/request", shortDate, region, serviceName) + stringToSign := fmt.Sprintf("HMAC-SHA256\n%s\n%s\n%s", + xDate, + credentialScope, + hexHashedCanonicalRequest, + ) + + kDate := hmacSHA256([]byte(secretKey), []byte(shortDate)) + kRegion := hmacSHA256(kDate, []byte(region)) + kService := hmacSHA256(kRegion, []byte(serviceName)) + kSigning := hmacSHA256(kService, []byte("request")) + signature := hex.EncodeToString(hmacSHA256(kSigning, []byte(stringToSign))) + + authorization := fmt.Sprintf("HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", + accessKey, + credentialScope, + signedHeaders, + signature, + ) + header.Set("Authorization", authorization) + return nil +} + +// hmacSHA256 计算 HMAC-SHA256 +func hmacSHA256(key []byte, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} diff --git a/relay/channel/jina/adaptor.go b/relay/channel/jina/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..3f2d01d9625fff5fcd96cfc96c7d819948d2f429 --- /dev/null +++ b/relay/channel/jina/adaptor.go @@ -0,0 +1,99 @@ +package jina + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/common_handler" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.RelayMode == constant.RelayModeRerank { + return fmt.Sprintf("%s/v1/rerank", info.ChannelBaseUrl), nil + } else if info.RelayMode == constant.RelayModeEmbeddings { + return fmt.Sprintf("%s/v1/embeddings", info.ChannelBaseUrl), nil + } + return "", errors.New("invalid relay mode") +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + request.EncodingFormat = "" + return request, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.RelayMode == constant.RelayModeRerank { + usage, err = common_handler.RerankHandler(c, info, resp) + } else if info.RelayMode == constant.RelayModeEmbeddings { + usage, err = openai.OpenaiHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/jina/constant.go b/relay/channel/jina/constant.go new file mode 100644 index 0000000000000000000000000000000000000000..be290fb69aad52969191962193f584349717a555 --- /dev/null +++ b/relay/channel/jina/constant.go @@ -0,0 +1,9 @@ +package jina + +var ModelList = []string{ + "jina-clip-v1", + "jina-reranker-v2-base-multilingual", + "jina-reranker-m0", +} + +var ChannelName = "jina" diff --git a/relay/channel/jina/relay-jina.go b/relay/channel/jina/relay-jina.go new file mode 100644 index 0000000000000000000000000000000000000000..d83b5854b3eb936047eac15e83f2dc50c781ba35 --- /dev/null +++ b/relay/channel/jina/relay-jina.go @@ -0,0 +1 @@ +package jina diff --git a/relay/channel/lingyiwanwu/constrants.go b/relay/channel/lingyiwanwu/constrants.go new file mode 100644 index 0000000000000000000000000000000000000000..a63450717357962f0397eeaa324460ed4c9f543e --- /dev/null +++ b/relay/channel/lingyiwanwu/constrants.go @@ -0,0 +1,9 @@ +package lingyiwanwu + +// https://platform.lingyiwanwu.com/docs + +var ModelList = []string{ + "yi-large", "yi-medium", "yi-vision", "yi-medium-200k", "yi-spark", "yi-large-rag", "yi-large-turbo", "yi-large-preview", "yi-large-rag-preview", +} + +var ChannelName = "lingyiwanwu" diff --git a/relay/channel/minimax/adaptor.go b/relay/channel/minimax/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..8235abc0534f8657db3f37c09046e9c43529bb9b --- /dev/null +++ b/relay/channel/minimax/adaptor.go @@ -0,0 +1,132 @@ +package minimax + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + if info.RelayMode != constant.RelayModeAudioSpeech { + return nil, errors.New("unsupported audio relay mode") + } + + voiceID := request.Voice + speed := request.Speed + outputFormat := request.ResponseFormat + + minimaxRequest := MiniMaxTTSRequest{ + Model: info.OriginModelName, + Text: request.Input, + VoiceSetting: VoiceSetting{ + VoiceID: voiceID, + Speed: speed, + }, + AudioSetting: &AudioSetting{ + Format: outputFormat, + }, + OutputFormat: outputFormat, + } + + // 同步扩展字段的厂商自定义metadata + if len(request.Metadata) > 0 { + if err := json.Unmarshal(request.Metadata, &minimaxRequest); err != nil { + return nil, fmt.Errorf("error unmarshalling metadata to minimax request: %w", err) + } + } + + jsonData, err := json.Marshal(minimaxRequest) + if err != nil { + return nil, fmt.Errorf("error marshalling minimax request: %w", err) + } + if outputFormat != "hex" { + outputFormat = "url" + } + + c.Set("response_format", outputFormat) + + // Debug: log the request structure + // fmt.Printf("MiniMax TTS Request: %s\n", string(jsonData)) + + return bytes.NewReader(jsonData), nil +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return GetRequestURL(info) +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.RelayMode == constant.RelayModeAudioSpeech { + return handleTTSResponse(c, resp, info) + } + + adaptor := openai.Adaptor{} + return adaptor.DoResponse(c, resp, info) +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/minimax/constants.go b/relay/channel/minimax/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..df420d4b12860606cbc3df637262da544756779a --- /dev/null +++ b/relay/channel/minimax/constants.go @@ -0,0 +1,19 @@ +package minimax + +// https://www.minimaxi.com/document/guides/chat-model/V2?id=65e0736ab2845de20908e2dd + +var ModelList = []string{ + "abab6.5-chat", + "abab6.5s-chat", + "abab6-chat", + "abab5.5-chat", + "abab5.5s-chat", + "speech-2.5-hd-preview", + "speech-2.5-turbo-preview", + "speech-02-hd", + "speech-02-turbo", + "speech-01-hd", + "speech-01-turbo", +} + +var ChannelName = "minimax" diff --git a/relay/channel/minimax/relay-minimax.go b/relay/channel/minimax/relay-minimax.go new file mode 100644 index 0000000000000000000000000000000000000000..b314e69d7b6906c47d8285ec27ec0f7ac518521e --- /dev/null +++ b/relay/channel/minimax/relay-minimax.go @@ -0,0 +1,25 @@ +package minimax + +import ( + "fmt" + + channelconstant "github.com/QuantumNous/new-api/constant" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" +) + +func GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + baseUrl := info.ChannelBaseUrl + if baseUrl == "" { + baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeMiniMax] + } + + switch info.RelayMode { + case constant.RelayModeChatCompletions: + return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil + case constant.RelayModeAudioSpeech: + return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil + default: + return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode) + } +} diff --git a/relay/channel/minimax/tts.go b/relay/channel/minimax/tts.go new file mode 100644 index 0000000000000000000000000000000000000000..4a52d2145f3967676d48c6e92b40523fb0f58da7 --- /dev/null +++ b/relay/channel/minimax/tts.go @@ -0,0 +1,194 @@ +package minimax + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +type MiniMaxTTSRequest struct { + Model string `json:"model"` + Text string `json:"text"` + Stream bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + VoiceSetting VoiceSetting `json:"voice_setting"` + PronunciationDict *PronunciationDict `json:"pronunciation_dict,omitempty"` + AudioSetting *AudioSetting `json:"audio_setting,omitempty"` + TimbreWeights []TimbreWeight `json:"timbre_weights,omitempty"` + LanguageBoost string `json:"language_boost,omitempty"` + VoiceModify *VoiceModify `json:"voice_modify,omitempty"` + SubtitleEnable bool `json:"subtitle_enable,omitempty"` + OutputFormat string `json:"output_format,omitempty"` + AigcWatermark bool `json:"aigc_watermark,omitempty"` +} + +type StreamOptions struct { + ExcludeAggregatedAudio bool `json:"exclude_aggregated_audio,omitempty"` +} + +type VoiceSetting struct { + VoiceID string `json:"voice_id"` + Speed float64 `json:"speed,omitempty"` + Vol float64 `json:"vol,omitempty"` + Pitch int `json:"pitch,omitempty"` + Emotion string `json:"emotion,omitempty"` + TextNormalization bool `json:"text_normalization,omitempty"` + LatexRead bool `json:"latex_read,omitempty"` +} + +type PronunciationDict struct { + Tone []string `json:"tone,omitempty"` +} + +type AudioSetting struct { + SampleRate int `json:"sample_rate,omitempty"` + Bitrate int `json:"bitrate,omitempty"` + Format string `json:"format,omitempty"` + Channel int `json:"channel,omitempty"` + ForceCbr bool `json:"force_cbr,omitempty"` +} + +type TimbreWeight struct { + VoiceID string `json:"voice_id"` + Weight int `json:"weight"` +} + +type VoiceModify struct { + Pitch int `json:"pitch,omitempty"` + Intensity int `json:"intensity,omitempty"` + Timbre int `json:"timbre,omitempty"` + SoundEffects string `json:"sound_effects,omitempty"` +} + +type MiniMaxTTSResponse struct { + Data MiniMaxTTSData `json:"data"` + ExtraInfo MiniMaxExtraInfo `json:"extra_info"` + TraceID string `json:"trace_id"` + BaseResp MiniMaxBaseResp `json:"base_resp"` +} + +type MiniMaxTTSData struct { + Audio string `json:"audio"` + Status int `json:"status"` +} + +type MiniMaxExtraInfo struct { + UsageCharacters int64 `json:"usage_characters"` +} + +type MiniMaxBaseResp struct { + StatusCode int64 `json:"status_code"` + StatusMsg string `json:"status_msg"` +} + +func getContentTypeByFormat(format string) string { + contentTypeMap := map[string]string{ + "mp3": "audio/mpeg", + "wav": "audio/wav", + "flac": "audio/flac", + "aac": "audio/aac", + "pcm": "audio/pcm", + } + if ct, ok := contentTypeMap[format]; ok { + return ct + } + return "audio/mpeg" // default to mp3 +} + +func handleTTSResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("failed to read minimax response: %w", readErr), + types.ErrorCodeReadResponseBodyFailed, + http.StatusInternalServerError, + ) + } + defer resp.Body.Close() + + // Parse response + var minimaxResp MiniMaxTTSResponse + if unmarshalErr := json.Unmarshal(body, &minimaxResp); unmarshalErr != nil { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("failed to unmarshal minimax TTS response: %w", unmarshalErr), + types.ErrorCodeBadResponseBody, + http.StatusInternalServerError, + ) + } + + // Check base_resp status code + if minimaxResp.BaseResp.StatusCode != 0 { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("minimax TTS error: %d - %s", minimaxResp.BaseResp.StatusCode, minimaxResp.BaseResp.StatusMsg), + types.ErrorCodeBadResponse, + http.StatusBadRequest, + ) + } + + // Check if we have audio data + if minimaxResp.Data.Audio == "" { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("no audio data in minimax TTS response"), + types.ErrorCodeBadResponse, + http.StatusBadRequest, + ) + } + + if strings.HasPrefix(minimaxResp.Data.Audio, "http") { + c.Redirect(http.StatusFound, minimaxResp.Data.Audio) + } else { + // Handle hex-encoded audio data + audioData, decodeErr := hex.DecodeString(minimaxResp.Data.Audio) + if decodeErr != nil { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("failed to decode hex audio data: %w", decodeErr), + types.ErrorCodeBadResponse, + http.StatusInternalServerError, + ) + } + + // Determine content type - default to mp3 + contentType := "audio/mpeg" + + c.Data(http.StatusOK, contentType, audioData) + } + + usage = &dto.Usage{ + PromptTokens: info.PromptTokens, + CompletionTokens: 0, + TotalTokens: int(minimaxResp.ExtraInfo.UsageCharacters), + } + + return usage, nil +} + +func handleChatCompletionResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, types.NewErrorWithStatusCode( + errors.New("failed to read minimax response"), + types.ErrorCodeReadResponseBodyFailed, + http.StatusInternalServerError, + ) + } + defer resp.Body.Close() + + // Set response headers + for key, values := range resp.Header { + for _, value := range values { + c.Header(key, value) + } + } + + c.Data(resp.StatusCode, "application/json", body) + return nil, nil +} diff --git a/relay/channel/mistral/adaptor.go b/relay/channel/mistral/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..88d72e0fc90dc8d6168b96c908d44d2de960bf7c --- /dev/null +++ b/relay/channel/mistral/adaptor.go @@ -0,0 +1,94 @@ +package mistral + +import ( + "errors" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return requestOpenAI2Mistral(request), nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/mistral/constants.go b/relay/channel/mistral/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..7f5f3acac6015f692c761a1561b42cf805b9a18b --- /dev/null +++ b/relay/channel/mistral/constants.go @@ -0,0 +1,12 @@ +package mistral + +var ModelList = []string{ + "open-mistral-7b", + "open-mixtral-8x7b", + "mistral-small-latest", + "mistral-medium-latest", + "mistral-large-latest", + "mistral-embed", +} + +var ChannelName = "mistral" diff --git a/relay/channel/mistral/text.go b/relay/channel/mistral/text.go new file mode 100644 index 0000000000000000000000000000000000000000..a6d48f68bb6a50d62c69d08a5367c61da323c2cf --- /dev/null +++ b/relay/channel/mistral/text.go @@ -0,0 +1,79 @@ +package mistral + +import ( + "regexp" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" +) + +var mistralToolCallIdRegexp = regexp.MustCompile("^[a-zA-Z0-9]{9}$") + +func requestOpenAI2Mistral(request *dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest { + messages := make([]dto.Message, 0, len(request.Messages)) + idMap := make(map[string]string) + for _, message := range request.Messages { + // 1. tool_calls.id + toolCalls := message.ParseToolCalls() + if toolCalls != nil { + for i := range toolCalls { + if !mistralToolCallIdRegexp.MatchString(toolCalls[i].ID) { + if newId, ok := idMap[toolCalls[i].ID]; ok { + toolCalls[i].ID = newId + } else { + newId, err := common.GenerateRandomCharsKey(9) + if err == nil { + idMap[toolCalls[i].ID] = newId + toolCalls[i].ID = newId + } + } + } + } + message.SetToolCalls(toolCalls) + } + + // 2. tool_call_id + if message.ToolCallId != "" { + if newId, ok := idMap[message.ToolCallId]; ok { + message.ToolCallId = newId + } else { + if !mistralToolCallIdRegexp.MatchString(message.ToolCallId) { + newId, err := common.GenerateRandomCharsKey(9) + if err == nil { + idMap[message.ToolCallId] = newId + message.ToolCallId = newId + } + } + } + } + + mediaMessages := message.ParseContent() + if message.Role == "assistant" && message.ToolCalls != nil && message.Content == "" { + mediaMessages = []dto.MediaContent{} + } + for j, mediaMessage := range mediaMessages { + if mediaMessage.Type == dto.ContentTypeImageURL { + imageUrl := mediaMessage.GetImageMedia() + mediaMessage.ImageUrl = imageUrl.Url + mediaMessages[j] = mediaMessage + } + } + message.SetMediaContent(mediaMessages) + messages = append(messages, dto.Message{ + Role: message.Role, + Content: message.Content, + ToolCalls: message.ToolCalls, + ToolCallId: message.ToolCallId, + }) + } + return &dto.GeneralOpenAIRequest{ + Model: request.Model, + Stream: request.Stream, + Messages: messages, + Temperature: request.Temperature, + TopP: request.TopP, + MaxTokens: request.GetMaxTokens(), + Tools: request.Tools, + ToolChoice: request.ToolChoice, + } +} diff --git a/relay/channel/mokaai/adaptor.go b/relay/channel/mokaai/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..f50c1e6be231924a70a04de7d02d9e8a259e9f72 --- /dev/null +++ b/relay/channel/mokaai/adaptor.go @@ -0,0 +1,112 @@ +package mokaai + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return request, nil +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + // https://cloud.baidu.com/doc/WENXINWORKSHOP/s/clntwmv7t + suffix := "chat/" + if strings.HasPrefix(info.UpstreamModelName, "m3e") { + suffix = "embeddings" + } + fullRequestURL := fmt.Sprintf("%s/%s", info.ChannelBaseUrl, suffix) + return fullRequestURL, nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + switch info.RelayMode { + case constant.RelayModeEmbeddings: + baiduEmbeddingRequest := embeddingRequestOpenAI2Moka(*request) + return baiduEmbeddingRequest, nil + default: + return nil, errors.New("not implemented") + } +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + + switch info.RelayMode { + case constant.RelayModeEmbeddings: + return mokaEmbeddingHandler(c, info, resp) + default: + // err, usage = mokaHandler(c, resp) + + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/mokaai/constants.go b/relay/channel/mokaai/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..385a0876b368769fb1458780e1e1ef3107011961 --- /dev/null +++ b/relay/channel/mokaai/constants.go @@ -0,0 +1,9 @@ +package mokaai + +var ModelList = []string{ + "m3e-large", + "m3e-base", + "m3e-small", +} + +var ChannelName = "mokaai" diff --git a/relay/channel/mokaai/relay-mokaai.go b/relay/channel/mokaai/relay-mokaai.go new file mode 100644 index 0000000000000000000000000000000000000000..4949ed64351750efb756f2b6b2eb6a0f31291578 --- /dev/null +++ b/relay/channel/mokaai/relay-mokaai.go @@ -0,0 +1,84 @@ +package mokaai + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func embeddingRequestOpenAI2Moka(request dto.GeneralOpenAIRequest) *dto.EmbeddingRequest { + var input []string // Change input to []string + + switch v := request.Input.(type) { + case string: + input = []string{v} // Convert string to []string + case []string: + input = v // Already a []string, no conversion needed + case []interface{}: + for _, part := range v { + if str, ok := part.(string); ok { + input = append(input, str) // Append each string to the slice + } + } + } + return &dto.EmbeddingRequest{ + Input: input, + Model: request.Model, + } +} + +func embeddingResponseMoka2OpenAI(response *dto.EmbeddingResponse) *dto.OpenAIEmbeddingResponse { + openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{ + Object: "list", + Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Data)), + Model: "baidu-embedding", + Usage: response.Usage, + } + for _, item := range response.Data { + openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, dto.OpenAIEmbeddingResponseItem{ + Object: item.Object, + Index: item.Index, + Embedding: item.Embedding, + }) + } + return &openAIEmbeddingResponse +} + +func mokaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var baiduResponse dto.EmbeddingResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + service.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &baiduResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + // if baiduResponse.ErrorMsg != "" { + // return &dto.OpenAIErrorWithStatusCode{ + // Error: dto.OpenAIError{ + // Type: "baidu_error", + // Param: "", + // }, + // StatusCode: resp.StatusCode, + // }, nil + // } + fullTextResponse := embeddingResponseMoka2OpenAI(&baiduResponse) + jsonResponse, err := common.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + service.IOCopyBytesGracefully(c, resp, jsonResponse) + return &fullTextResponse.Usage, nil +} diff --git a/relay/channel/moonshot/adaptor.go b/relay/channel/moonshot/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..f02cb5e87462be5568e4bcf2e9c209bc2d0a2e3a --- /dev/null +++ b/relay/channel/moonshot/adaptor.go @@ -0,0 +1,111 @@ +package moonshot + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/claude" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := claude.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not supported") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + adaptor := openai.Adaptor{} + return adaptor.ConvertImageRequest(c, info, request) +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + switch info.RelayFormat { + case types.RelayFormatClaude: + return fmt.Sprintf("%s/anthropic/v1/messages", info.ChannelBaseUrl), nil + default: + if info.RelayMode == constant.RelayModeRerank { + return fmt.Sprintf("%s/v1/rerank", info.ChannelBaseUrl), nil + } else if info.RelayMode == constant.RelayModeEmbeddings { + return fmt.Sprintf("%s/v1/embeddings", info.ChannelBaseUrl), nil + } else if info.RelayMode == constant.RelayModeChatCompletions { + return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil + } else if info.RelayMode == constant.RelayModeCompletions { + return fmt.Sprintf("%s/v1/completions", info.ChannelBaseUrl), nil + } + return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayFormat { + case types.RelayFormatClaude: + if info.IsStream { + return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + } else { + return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) + } + default: + adaptor := openai.Adaptor{} + return adaptor.DoResponse(c, resp, info) + } +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/moonshot/constants.go b/relay/channel/moonshot/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..a7da54b33ccd65b2e8a80c0ff6b3e42d127bf501 --- /dev/null +++ b/relay/channel/moonshot/constants.go @@ -0,0 +1,9 @@ +package moonshot + +var ModelList = []string{ + "moonshot-v1-8k", + "moonshot-v1-32k", + "moonshot-v1-128k", +} + +var ChannelName = "moonshot" diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..a3013e2fbd0ab34e3200215ab3658d2cbba9bafc --- /dev/null +++ b/relay/channel/ollama/adaptor.go @@ -0,0 +1,111 @@ +package ollama + +import ( + "errors" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + openaiAdaptor := openai.Adaptor{} + openaiRequest, err := openaiAdaptor.ConvertClaudeRequest(c, info, request) + if err != nil { + return nil, err + } + openaiRequest.(*dto.GeneralOpenAIRequest).StreamOptions = &dto.StreamOptions{ + IncludeUsage: true, + } + // map to ollama chat request (Claude -> OpenAI -> Ollama chat) + return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest)) +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.RelayMode == relayconstant.RelayModeEmbeddings { + return info.ChannelBaseUrl + "/api/embed", nil + } + if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { + return info.ChannelBaseUrl + "/api/generate", nil + } + return info.ChannelBaseUrl + "/api/chat", nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + // decide generate or chat + if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { + return openAIToGenerate(c, request) + } + return openAIChatToOllamaChat(c, request) +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return requestOpenAI2Embeddings(request), nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayMode { + case relayconstant.RelayModeEmbeddings: + return ollamaEmbeddingHandler(c, info, resp) + default: + if info.IsStream { + return ollamaStreamHandler(c, info, resp) + } + return ollamaChatHandler(c, info, resp) + } +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/ollama/constants.go b/relay/channel/ollama/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..682626a261ddaab392cd1252ca7e5ecf67af5356 --- /dev/null +++ b/relay/channel/ollama/constants.go @@ -0,0 +1,7 @@ +package ollama + +var ModelList = []string{ + "llama3-7b", +} + +var ChannelName = "ollama" diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..2434a4cbc08a75f3f7d57b37ef20041b27fe0d98 --- /dev/null +++ b/relay/channel/ollama/dto.go @@ -0,0 +1,69 @@ +package ollama + +import ( + "encoding/json" +) + +type OllamaChatMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + Images []string `json:"images,omitempty"` + ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"` + ToolName string `json:"tool_name,omitempty"` + Thinking json.RawMessage `json:"thinking,omitempty"` +} + +type OllamaToolFunction struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters interface{} `json:"parameters,omitempty"` +} + +type OllamaTool struct { + Type string `json:"type"` + Function OllamaToolFunction `json:"function"` +} + +type OllamaToolCall struct { + Function struct { + Name string `json:"name"` + Arguments interface{} `json:"arguments"` + } `json:"function"` +} + +type OllamaChatRequest struct { + Model string `json:"model"` + Messages []OllamaChatMessage `json:"messages"` + Tools interface{} `json:"tools,omitempty"` + Format interface{} `json:"format,omitempty"` + Stream bool `json:"stream,omitempty"` + Options map[string]any `json:"options,omitempty"` + KeepAlive interface{} `json:"keep_alive,omitempty"` + Think json.RawMessage `json:"think,omitempty"` +} + +type OllamaGenerateRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt,omitempty"` + Suffix string `json:"suffix,omitempty"` + Images []string `json:"images,omitempty"` + Format interface{} `json:"format,omitempty"` + Stream bool `json:"stream,omitempty"` + Options map[string]any `json:"options,omitempty"` + KeepAlive interface{} `json:"keep_alive,omitempty"` + Think json.RawMessage `json:"think,omitempty"` +} + +type OllamaEmbeddingRequest struct { + Model string `json:"model"` + Input interface{} `json:"input"` + Options map[string]any `json:"options,omitempty"` + Dimensions int `json:"dimensions,omitempty"` +} + +type OllamaEmbeddingResponse struct { + Error string `json:"error,omitempty"` + Model string `json:"model"` + Embeddings [][]float64 `json:"embeddings"` + PromptEvalCount int `json:"prompt_eval_count,omitempty"` +} diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go new file mode 100644 index 0000000000000000000000000000000000000000..9c05b135736b36b15edabb894ff8d6d4c1c8346e --- /dev/null +++ b/relay/channel/ollama/relay-ollama.go @@ -0,0 +1,285 @@ +package ollama + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaChatRequest, error) { + chatReq := &OllamaChatRequest{ + Model: r.Model, + Stream: r.Stream, + Options: map[string]any{}, + Think: r.Think, + } + if r.ResponseFormat != nil { + if r.ResponseFormat.Type == "json" { + chatReq.Format = "json" + } else if r.ResponseFormat.Type == "json_schema" { + if len(r.ResponseFormat.JsonSchema) > 0 { + var schema any + _ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema) + chatReq.Format = schema + } + } + } + + // options mapping + if r.Temperature != nil { + chatReq.Options["temperature"] = r.Temperature + } + if r.TopP != 0 { + chatReq.Options["top_p"] = r.TopP + } + if r.TopK != 0 { + chatReq.Options["top_k"] = r.TopK + } + if r.FrequencyPenalty != 0 { + chatReq.Options["frequency_penalty"] = r.FrequencyPenalty + } + if r.PresencePenalty != 0 { + chatReq.Options["presence_penalty"] = r.PresencePenalty + } + if r.Seed != 0 { + chatReq.Options["seed"] = int(r.Seed) + } + if mt := r.GetMaxTokens(); mt != 0 { + chatReq.Options["num_predict"] = int(mt) + } + + if r.Stop != nil { + switch v := r.Stop.(type) { + case string: + chatReq.Options["stop"] = []string{v} + case []string: + chatReq.Options["stop"] = v + case []any: + arr := make([]string, 0, len(v)) + for _, i := range v { + if s, ok := i.(string); ok { + arr = append(arr, s) + } + } + if len(arr) > 0 { + chatReq.Options["stop"] = arr + } + } + } + + if len(r.Tools) > 0 { + tools := make([]OllamaTool, 0, len(r.Tools)) + for _, t := range r.Tools { + tools = append(tools, OllamaTool{Type: "function", Function: OllamaToolFunction{Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters}}) + } + chatReq.Tools = tools + } + + chatReq.Messages = make([]OllamaChatMessage, 0, len(r.Messages)) + for _, m := range r.Messages { + var textBuilder strings.Builder + var images []string + if m.IsStringContent() { + textBuilder.WriteString(m.StringContent()) + } else { + parts := m.ParseContent() + for _, part := range parts { + if part.Type == dto.ContentTypeImageURL { + img := part.GetImageMedia() + if img != nil && img.Url != "" { + var base64Data string + if strings.HasPrefix(img.Url, "http") { + fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat") + if err != nil { + return nil, err + } + base64Data = fileData.Base64Data + } else if strings.HasPrefix(img.Url, "data:") { + if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) { + base64Data = img.Url[idx+1:] + } + } else { + base64Data = img.Url + } + if base64Data != "" { + images = append(images, base64Data) + } + } + } else if part.Type == dto.ContentTypeText { + textBuilder.WriteString(part.Text) + } + } + } + cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()} + if len(images) > 0 { + cm.Images = images + } + if m.Role == "tool" && m.Name != nil { + cm.ToolName = *m.Name + } + if m.ToolCalls != nil && len(m.ToolCalls) > 0 { + parsed := m.ParseToolCalls() + if len(parsed) > 0 { + calls := make([]OllamaToolCall, 0, len(parsed)) + for _, tc := range parsed { + var args interface{} + if tc.Function.Arguments != "" { + _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) + } + if args == nil { + args = map[string]any{} + } + oc := OllamaToolCall{} + oc.Function.Name = tc.Function.Name + oc.Function.Arguments = args + calls = append(calls, oc) + } + cm.ToolCalls = calls + } + } + chatReq.Messages = append(chatReq.Messages, cm) + } + return chatReq, nil +} + +// openAIToGenerate converts OpenAI completions request to Ollama generate +func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGenerateRequest, error) { + gen := &OllamaGenerateRequest{ + Model: r.Model, + Stream: r.Stream, + Options: map[string]any{}, + Think: r.Think, + } + // Prompt may be in r.Prompt (string or []any) + if r.Prompt != nil { + switch v := r.Prompt.(type) { + case string: + gen.Prompt = v + case []any: + var sb strings.Builder + for _, it := range v { + if s, ok := it.(string); ok { + sb.WriteString(s) + } + } + gen.Prompt = sb.String() + default: + gen.Prompt = fmt.Sprintf("%v", r.Prompt) + } + } + if r.Suffix != nil { + if s, ok := r.Suffix.(string); ok { + gen.Suffix = s + } + } + if r.ResponseFormat != nil { + if r.ResponseFormat.Type == "json" { + gen.Format = "json" + } else if r.ResponseFormat.Type == "json_schema" { + var schema any + _ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema) + gen.Format = schema + } + } + if r.Temperature != nil { + gen.Options["temperature"] = r.Temperature + } + if r.TopP != 0 { + gen.Options["top_p"] = r.TopP + } + if r.TopK != 0 { + gen.Options["top_k"] = r.TopK + } + if r.FrequencyPenalty != 0 { + gen.Options["frequency_penalty"] = r.FrequencyPenalty + } + if r.PresencePenalty != 0 { + gen.Options["presence_penalty"] = r.PresencePenalty + } + if r.Seed != 0 { + gen.Options["seed"] = int(r.Seed) + } + if mt := r.GetMaxTokens(); mt != 0 { + gen.Options["num_predict"] = int(mt) + } + if r.Stop != nil { + switch v := r.Stop.(type) { + case string: + gen.Options["stop"] = []string{v} + case []string: + gen.Options["stop"] = v + case []any: + arr := make([]string, 0, len(v)) + for _, i := range v { + if s, ok := i.(string); ok { + arr = append(arr, s) + } + } + if len(arr) > 0 { + gen.Options["stop"] = arr + } + } + } + return gen, nil +} + +func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest { + opts := map[string]any{} + if r.Temperature != nil { + opts["temperature"] = r.Temperature + } + if r.TopP != 0 { + opts["top_p"] = r.TopP + } + if r.FrequencyPenalty != 0 { + opts["frequency_penalty"] = r.FrequencyPenalty + } + if r.PresencePenalty != 0 { + opts["presence_penalty"] = r.PresencePenalty + } + if r.Seed != 0 { + opts["seed"] = int(r.Seed) + } + if r.Dimensions != 0 { + opts["dimensions"] = r.Dimensions + } + input := r.ParseInput() + if len(input) == 1 { + return &OllamaEmbeddingRequest{Model: r.Model, Input: input[0], Options: opts, Dimensions: r.Dimensions} + } + return &OllamaEmbeddingRequest{Model: r.Model, Input: input, Options: opts, Dimensions: r.Dimensions} +} + +func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var oResp OllamaEmbeddingResponse + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + if err = common.Unmarshal(body, &oResp); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if oResp.Error != "" { + return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + data := make([]dto.OpenAIEmbeddingResponseItem, 0, len(oResp.Embeddings)) + for i, emb := range oResp.Embeddings { + data = append(data, dto.OpenAIEmbeddingResponseItem{Index: i, Object: "embedding", Embedding: emb}) + } + usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens: 0, TotalTokens: oResp.PromptEvalCount} + embResp := &dto.OpenAIEmbeddingResponse{Object: "list", Data: data, Model: info.UpstreamModelName, Usage: *usage} + out, _ := common.Marshal(embResp) + service.IOCopyBytesGracefully(c, resp, out) + return usage, nil +} diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go new file mode 100644 index 0000000000000000000000000000000000000000..2a264b27e467a7c20f0dc4b822829ab8460deacc --- /dev/null +++ b/relay/channel/ollama/stream.go @@ -0,0 +1,300 @@ +package ollama + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type ollamaChatStreamChunk struct { + Model string `json:"model"` + CreatedAt string `json:"created_at"` + // chat + Message *struct { + Role string `json:"role"` + Content string `json:"content"` + Thinking json.RawMessage `json:"thinking"` + ToolCalls []struct { + Function struct { + Name string `json:"name"` + Arguments interface{} `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"message"` + // generate + Response string `json:"response"` + Done bool `json:"done"` + DoneReason string `json:"done_reason"` + TotalDuration int64 `json:"total_duration"` + LoadDuration int64 `json:"load_duration"` + PromptEvalCount int `json:"prompt_eval_count"` + EvalCount int `json:"eval_count"` + PromptEvalDuration int64 `json:"prompt_eval_duration"` + EvalDuration int64 `json:"eval_duration"` +} + +func toUnix(ts string) int64 { + if ts == "" { + return time.Now().Unix() + } + // try time.RFC3339 or with nanoseconds + t, err := time.Parse(time.RFC3339Nano, ts) + if err != nil { + t2, err2 := time.Parse(time.RFC3339, ts) + if err2 == nil { + return t2.Unix() + } + return time.Now().Unix() + } + return t.Unix() +} + +func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + if resp == nil || resp.Body == nil { + return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) + } + defer service.CloseResponseBodyGracefully(resp) + + helper.SetEventStreamHeaders(c) + scanner := bufio.NewScanner(resp.Body) + usage := &dto.Usage{} + var model = info.UpstreamModelName + var responseId = common.GetUUID() + var created = time.Now().Unix() + var toolCallIndex int + start := helper.GenerateStartEmptyResponse(responseId, created, model, nil) + if data, err := common.Marshal(start); err == nil { + _ = helper.StringData(c, string(data)) + } + + for scanner.Scan() { + line := scanner.Text() + line = strings.TrimSpace(line) + if line == "" { + continue + } + var chunk ollamaChatStreamChunk + if err := json.Unmarshal([]byte(line), &chunk); err != nil { + logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line) + return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if chunk.Model != "" { + model = chunk.Model + } + created = toUnix(chunk.CreatedAt) + + if !chunk.Done { + // delta content + var content string + if chunk.Message != nil { + content = chunk.Message.Content + } else { + content = chunk.Response + } + delta := dto.ChatCompletionsStreamResponse{ + Id: responseId, + Object: "chat.completion.chunk", + Created: created, + Model: model, + Choices: []dto.ChatCompletionsStreamResponseChoice{{ + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"}, + }}, + } + if content != "" { + delta.Choices[0].Delta.SetContentString(content) + } + if chunk.Message != nil && len(chunk.Message.Thinking) > 0 { + raw := strings.TrimSpace(string(chunk.Message.Thinking)) + if raw != "" && raw != "null" { + // Unmarshal the JSON string to get the actual content without quotes + var thinkingContent string + if err := json.Unmarshal(chunk.Message.Thinking, &thinkingContent); err == nil { + delta.Choices[0].Delta.SetReasoningContent(thinkingContent) + } else { + // Fallback to raw string if it's not a JSON string + delta.Choices[0].Delta.SetReasoningContent(raw) + } + } + } + // tool calls + if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 { + delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse, 0, len(chunk.Message.ToolCalls)) + for _, tc := range chunk.Message.ToolCalls { + // arguments -> string + argBytes, _ := json.Marshal(tc.Function.Arguments) + toolId := fmt.Sprintf("call_%d", toolCallIndex) + tr := dto.ToolCallResponse{ID: toolId, Type: "function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}} + tr.SetIndex(toolCallIndex) + toolCallIndex++ + delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr) + } + } + if data, err := common.Marshal(delta); err == nil { + _ = helper.StringData(c, string(data)) + } + continue + } + // done frame + // finalize once and break loop + usage.PromptTokens = chunk.PromptEvalCount + usage.CompletionTokens = chunk.EvalCount + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + finishReason := chunk.DoneReason + if finishReason == "" { + finishReason = "stop" + } + // emit stop delta + if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil { + if data, err := common.Marshal(stop); err == nil { + _ = helper.StringData(c, string(data)) + } + } + // emit usage frame + if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil { + if data, err := common.Marshal(final); err == nil { + _ = helper.StringData(c, string(data)) + } + } + // send [DONE] + helper.Done(c) + break + } + if err := scanner.Err(); err != nil && err != io.EOF { + logger.LogError(c, "ollama stream scan error: "+err.Error()) + } + return usage, nil +} + +// non-stream handler for chat/generate +func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + raw := string(body) + if common.DebugEnabled { + println("ollama non-stream raw resp:", raw) + } + + lines := strings.Split(raw, "\n") + var ( + aggContent strings.Builder + reasoningBuilder strings.Builder + lastChunk ollamaChatStreamChunk + parsedAny bool + ) + for _, ln := range lines { + ln = strings.TrimSpace(ln) + if ln == "" { + continue + } + var ck ollamaChatStreamChunk + if err := json.Unmarshal([]byte(ln), &ck); err != nil { + if len(lines) == 1 { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + continue + } + parsedAny = true + lastChunk = ck + if ck.Message != nil && len(ck.Message.Thinking) > 0 { + raw := strings.TrimSpace(string(ck.Message.Thinking)) + if raw != "" && raw != "null" { + // Unmarshal the JSON string to get the actual content without quotes + var thinkingContent string + if err := json.Unmarshal(ck.Message.Thinking, &thinkingContent); err == nil { + reasoningBuilder.WriteString(thinkingContent) + } else { + // Fallback to raw string if it's not a JSON string + reasoningBuilder.WriteString(raw) + } + } + } + if ck.Message != nil && ck.Message.Content != "" { + aggContent.WriteString(ck.Message.Content) + } else if ck.Response != "" { + aggContent.WriteString(ck.Response) + } + } + + if !parsedAny { + var single ollamaChatStreamChunk + if err := json.Unmarshal(body, &single); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + lastChunk = single + if single.Message != nil { + if len(single.Message.Thinking) > 0 { + raw := strings.TrimSpace(string(single.Message.Thinking)) + if raw != "" && raw != "null" { + // Unmarshal the JSON string to get the actual content without quotes + var thinkingContent string + if err := json.Unmarshal(single.Message.Thinking, &thinkingContent); err == nil { + reasoningBuilder.WriteString(thinkingContent) + } else { + // Fallback to raw string if it's not a JSON string + reasoningBuilder.WriteString(raw) + } + } + } + aggContent.WriteString(single.Message.Content) + } else { + aggContent.WriteString(single.Response) + } + } + + model := lastChunk.Model + if model == "" { + model = info.UpstreamModelName + } + created := toUnix(lastChunk.CreatedAt) + usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount} + content := aggContent.String() + finishReason := lastChunk.DoneReason + if finishReason == "" { + finishReason = "stop" + } + + msg := dto.Message{Role: "assistant", Content: contentPtr(content)} + if rc := reasoningBuilder.String(); rc != "" { + msg.ReasoningContent = rc + } + full := dto.OpenAITextResponse{ + Id: common.GetUUID(), + Model: model, + Object: "chat.completion", + Created: created, + Choices: []dto.OpenAITextResponseChoice{{ + Index: 0, + Message: msg, + FinishReason: finishReason, + }}, + Usage: *usage, + } + out, _ := common.Marshal(full) + service.IOCopyBytesGracefully(c, resp, out) + return usage, nil +} + +func contentPtr(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..55bd1402ca8bb4b4fddd39871c299798439e4a76 --- /dev/null +++ b/relay/channel/openai/adaptor.go @@ -0,0 +1,649 @@ +package openai + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "path/filepath" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/ai360" + "github.com/QuantumNous/new-api/relay/channel/lingyiwanwu" + + //"github.com/QuantumNous/new-api/relay/channel/minimax" + "github.com/QuantumNous/new-api/relay/channel/openrouter" + "github.com/QuantumNous/new-api/relay/channel/xinference" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/common_handler" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { + ChannelType int + ResponseFormat string +} + +// parseReasoningEffortFromModelSuffix 从模型名称中解析推理级别 +// support OAI models: o1-mini/o3-mini/o4-mini/o1/o3 etc... +// minimal effort only available in gpt-5 +func parseReasoningEffortFromModelSuffix(model string) (string, string) { + effortSuffixes := []string{"-high", "-minimal", "-low", "-medium", "-none"} + for _, suffix := range effortSuffixes { + if strings.HasSuffix(model, suffix) { + effort := strings.TrimPrefix(suffix, "-") + originModel := strings.TrimSuffix(model, suffix) + return effort, originModel + } + } + return "", model +} + +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + // 使用 service.GeminiToOpenAIRequest 转换请求格式 + openaiRequest, err := service.GeminiToOpenAIRequest(request, info) + if err != nil { + return nil, err + } + return a.ConvertOpenAIRequest(c, info, openaiRequest) +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + //if !strings.Contains(request.Model, "claude") { + // return nil, fmt.Errorf("you are using openai channel type with path /v1/messages, only claude model supported convert, but got %s", request.Model) + //} + //if common.DebugEnabled { + // bodyBytes := []byte(common.GetJsonString(request)) + // err := os.WriteFile(fmt.Sprintf("claude_request_%s.txt", c.GetString(common.RequestIdKey)), bodyBytes, 0644) + // if err != nil { + // println(fmt.Sprintf("failed to save request body to file: %v", err)) + // } + //} + aiRequest, err := service.ClaudeToOpenAIRequest(*request, info) + if err != nil { + return nil, err + } + //if common.DebugEnabled { + // println(fmt.Sprintf("convert claude to openai request result: %s", common.GetJsonString(aiRequest))) + // // Save request body to file for debugging + // bodyBytes := []byte(common.GetJsonString(aiRequest)) + // err = os.WriteFile(fmt.Sprintf("claude_to_openai_request_%s.txt", c.GetString(common.RequestIdKey)), bodyBytes, 0644) + // if err != nil { + // println(fmt.Sprintf("failed to save request body to file: %v", err)) + // } + //} + if info.SupportStreamOptions && info.IsStream { + aiRequest.StreamOptions = &dto.StreamOptions{ + IncludeUsage: true, + } + } + return a.ConvertOpenAIRequest(c, info, aiRequest) +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + + // initialize ThinkingContentInfo when thinking_to_content is enabled + if info.ChannelSetting.ThinkingToContent { + info.ThinkingContentInfo = relaycommon.ThinkingContentInfo{ + IsFirstThinkingContent: true, + SendLastThinkingContent: false, + HasSentThinkingContent: false, + } + } +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.RelayMode == relayconstant.RelayModeRealtime { + if strings.HasPrefix(info.ChannelBaseUrl, "https://") { + baseUrl := strings.TrimPrefix(info.ChannelBaseUrl, "https://") + baseUrl = "wss://" + baseUrl + info.ChannelBaseUrl = baseUrl + } else if strings.HasPrefix(info.ChannelBaseUrl, "http://") { + baseUrl := strings.TrimPrefix(info.ChannelBaseUrl, "http://") + baseUrl = "ws://" + baseUrl + info.ChannelBaseUrl = baseUrl + } + } + switch info.ChannelType { + case constant.ChannelTypeAzure: + apiVersion := info.ApiVersion + if apiVersion == "" { + apiVersion = constant.AzureDefaultAPIVersion + } + // https://learn.microsoft.com/en-us/azure/cognitive-services/openai/chatgpt-quickstart?pivots=rest-api&tabs=command-line#rest-api + requestURL := strings.Split(info.RequestURLPath, "?")[0] + requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, apiVersion) + task := strings.TrimPrefix(requestURL, "/v1/") + + if info.RelayFormat == types.RelayFormatClaude { + task = strings.TrimPrefix(task, "messages") + task = "chat/completions" + task + } + + // 特殊处理 responses API + if info.RelayMode == relayconstant.RelayModeResponses { + responsesApiVersion := "preview" + + subUrl := "/openai/v1/responses" + if strings.Contains(info.ChannelBaseUrl, "cognitiveservices.azure.com") { + subUrl = "/openai/responses" + responsesApiVersion = apiVersion + } + + if info.ChannelOtherSettings.AzureResponsesVersion != "" { + responsesApiVersion = info.ChannelOtherSettings.AzureResponsesVersion + } + + requestURL = fmt.Sprintf("%s?api-version=%s", subUrl, responsesApiVersion) + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, requestURL, info.ChannelType), nil + } + + model_ := info.UpstreamModelName + // 2025年5月10日后创建的渠道不移除. + if info.ChannelCreateTime < constant.AzureNoRemoveDotTime { + model_ = strings.Replace(model_, ".", "", -1) + } + // https://github.com/songquanpeng/one-api/issues/67 + requestURL = fmt.Sprintf("/openai/deployments/%s/%s", model_, task) + if info.RelayMode == relayconstant.RelayModeRealtime { + requestURL = fmt.Sprintf("/openai/realtime?deployment=%s&api-version=%s", model_, apiVersion) + } + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, requestURL, info.ChannelType), nil + //case constant.ChannelTypeMiniMax: + // return minimax.GetRequestURL(info) + case constant.ChannelTypeCustom: + url := info.ChannelBaseUrl + url = strings.Replace(url, "{model}", info.UpstreamModelName, -1) + return url, nil + default: + if info.RelayFormat == types.RelayFormatClaude || info.RelayFormat == types.RelayFormatGemini { + return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil + } + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, header) + if info.ChannelType == constant.ChannelTypeAzure { + header.Set("api-key", info.ApiKey) + return nil + } + if info.ChannelType == constant.ChannelTypeOpenAI && "" != info.Organization { + header.Set("OpenAI-Organization", info.Organization) + } + if info.RelayMode == relayconstant.RelayModeRealtime { + swp := c.Request.Header.Get("Sec-WebSocket-Protocol") + if swp != "" { + items := []string{ + "realtime", + "openai-insecure-api-key." + info.ApiKey, + "openai-beta.realtime-v1", + } + header.Set("Sec-WebSocket-Protocol", strings.Join(items, ",")) + //req.Header.Set("Sec-WebSocket-Key", c.Request.Header.Get("Sec-WebSocket-Key")) + //req.Header.Set("Sec-Websocket-Extensions", c.Request.Header.Get("Sec-Websocket-Extensions")) + //req.Header.Set("Sec-Websocket-Version", c.Request.Header.Get("Sec-Websocket-Version")) + } else { + header.Set("openai-beta", "realtime=v1") + header.Set("Authorization", "Bearer "+info.ApiKey) + } + } else { + header.Set("Authorization", "Bearer "+info.ApiKey) + } + if info.ChannelType == constant.ChannelTypeOpenRouter { + header.Set("HTTP-Referer", "https://www.newapi.ai") + header.Set("X-Title", "New API") + } + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if info.ChannelType != constant.ChannelTypeOpenAI && info.ChannelType != constant.ChannelTypeAzure { + request.StreamOptions = nil + } + if info.ChannelType == constant.ChannelTypeOpenRouter { + if len(request.Usage) == 0 { + request.Usage = json.RawMessage(`{"include":true}`) + } + // 适配 OpenRouter 的 thinking 后缀 + if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) && + strings.HasSuffix(info.UpstreamModelName, "-thinking") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") + request.Model = info.UpstreamModelName + if len(request.Reasoning) == 0 { + reasoning := map[string]any{ + "enabled": true, + } + if request.ReasoningEffort != "" && request.ReasoningEffort != "none" { + reasoning["effort"] = request.ReasoningEffort + } + marshal, err := common.Marshal(reasoning) + if err != nil { + return nil, fmt.Errorf("error marshalling reasoning: %w", err) + } + request.Reasoning = marshal + } + // 清空多余的ReasoningEffort + request.ReasoningEffort = "" + } else { + if len(request.Reasoning) == 0 { + // 适配 OpenAI 的 ReasoningEffort 格式 + if request.ReasoningEffort != "" { + reasoning := map[string]any{ + "enabled": true, + } + if request.ReasoningEffort != "none" { + reasoning["effort"] = request.ReasoningEffort + marshal, err := common.Marshal(reasoning) + if err != nil { + return nil, fmt.Errorf("error marshalling reasoning: %w", err) + } + request.Reasoning = marshal + } + } + } + request.ReasoningEffort = "" + } + + // https://docs.anthropic.com/en/api/openai-sdk#extended-thinking-support + // 没有做排除3.5Haiku等,要出问题再加吧,最佳兼容性(不是 + if request.THINKING != nil && strings.HasPrefix(info.UpstreamModelName, "anthropic") { + var thinking dto.Thinking // Claude标准Thinking格式 + if err := json.Unmarshal(request.THINKING, &thinking); err != nil { + return nil, fmt.Errorf("error Unmarshal thinking: %w", err) + } + + // 只有当 thinking.Type 是 "enabled" 时才处理 + if thinking.Type == "enabled" { + // 检查 BudgetTokens 是否为 nil + if thinking.BudgetTokens == nil { + return nil, fmt.Errorf("BudgetTokens is nil when thinking is enabled") + } + + reasoning := openrouter.RequestReasoning{ + MaxTokens: *thinking.BudgetTokens, + } + + marshal, err := common.Marshal(reasoning) + if err != nil { + return nil, fmt.Errorf("error marshalling reasoning: %w", err) + } + + request.Reasoning = marshal + } + + // 清空 THINKING + request.THINKING = nil + } + + } + if strings.HasPrefix(info.UpstreamModelName, "o") || strings.HasPrefix(info.UpstreamModelName, "gpt-5") { + if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 { + request.MaxCompletionTokens = request.MaxTokens + request.MaxTokens = 0 + } + + if strings.HasPrefix(info.UpstreamModelName, "o") { + request.Temperature = nil + } + + if strings.HasPrefix(info.UpstreamModelName, "gpt-5") { + if info.UpstreamModelName != "gpt-5-chat-latest" { + request.Temperature = nil + } + } + + // 转换模型推理力度后缀 + effort, originModel := parseReasoningEffortFromModelSuffix(info.UpstreamModelName) + if effort != "" { + request.ReasoningEffort = effort + info.UpstreamModelName = originModel + request.Model = originModel + } + + info.ReasoningEffort = request.ReasoningEffort + + // o系列模型developer适配(o1-mini除外) + if !strings.HasPrefix(info.UpstreamModelName, "o1-mini") && !strings.HasPrefix(info.UpstreamModelName, "o1-preview") { + //修改第一个Message的内容,将system改为developer + if len(request.Messages) > 0 && request.Messages[0].Role == "system" { + request.Messages[0].Role = "developer" + } + } + } + + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + a.ResponseFormat = request.ResponseFormat + if info.RelayMode == relayconstant.RelayModeAudioSpeech { + jsonData, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("error marshalling object: %w", err) + } + return bytes.NewReader(jsonData), nil + } else { + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + writer.WriteField("model", request.Model) + + formData, err2 := common.ParseMultipartFormReusable(c) + if err2 != nil { + return nil, fmt.Errorf("error parsing multipart form: %w", err2) + } + + // 打印类似 curl 命令格式的信息 + logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form 'model=\"%s\"'", request.Model)) + + // 遍历表单字段并打印输出 + for key, values := range formData.Value { + if key == "model" { + continue + } + for _, value := range values { + writer.WriteField(key, value) + logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form '%s=\"%s\"'", key, value)) + } + } + + // 从 formData 中获取文件 + fileHeaders := formData.File["file"] + if len(fileHeaders) == 0 { + return nil, errors.New("file is required") + } + + // 使用 formData 中的第一个文件 + fileHeader := fileHeaders[0] + logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form 'file=@\"%s\"' (size: %d bytes, content-type: %s)", + fileHeader.Filename, fileHeader.Size, fileHeader.Header.Get("Content-Type"))) + + file, err := fileHeader.Open() + if err != nil { + return nil, fmt.Errorf("error opening audio file: %v", err) + } + defer file.Close() + + part, err := writer.CreateFormFile("file", fileHeader.Filename) + if err != nil { + return nil, errors.New("create form file failed") + } + if _, err := io.Copy(part, file); err != nil { + return nil, errors.New("copy file failed") + } + + // 关闭 multipart 编写器以设置分界线 + writer.Close() + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + logger.LogDebug(c.Request.Context(), fmt.Sprintf("--header 'Content-Type: %s'", writer.FormDataContentType())) + return &requestBody, nil + } +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + switch info.RelayMode { + case relayconstant.RelayModeImagesEdits: + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + writer.WriteField("model", request.Model) + // 使用已解析的 multipart 表单,避免重复解析 + mf := c.Request.MultipartForm + if mf == nil { + if _, err := c.MultipartForm(); err != nil { + return nil, errors.New("failed to parse multipart form") + } + mf = c.Request.MultipartForm + } + + // 写入所有非文件字段 + if mf != nil { + for key, values := range mf.Value { + if key == "model" { + continue + } + for _, value := range values { + writer.WriteField(key, value) + } + } + } + + if mf != nil && mf.File != nil { + // Check if "image" field exists in any form, including array notation + var imageFiles []*multipart.FileHeader + var exists bool + + // First check for standard "image" field + if imageFiles, exists = mf.File["image"]; !exists || len(imageFiles) == 0 { + // If not found, check for "image[]" field + if imageFiles, exists = mf.File["image[]"]; !exists || len(imageFiles) == 0 { + // If still not found, iterate through all fields to find any that start with "image[" + foundArrayImages := false + for fieldName, files := range mf.File { + if strings.HasPrefix(fieldName, "image[") && len(files) > 0 { + foundArrayImages = true + imageFiles = append(imageFiles, files...) + } + } + + // If no image fields found at all + if !foundArrayImages && (len(imageFiles) == 0) { + return nil, errors.New("image is required") + } + } + } + + // Process all image files + for i, fileHeader := range imageFiles { + file, err := fileHeader.Open() + if err != nil { + return nil, fmt.Errorf("failed to open image file %d: %w", i, err) + } + + // If multiple images, use image[] as the field name + fieldName := "image" + if len(imageFiles) > 1 { + fieldName = "image[]" + } + + // Determine MIME type based on file extension + mimeType := detectImageMimeType(fileHeader.Filename) + + // Create a form file with the appropriate content type + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileHeader.Filename)) + h.Set("Content-Type", mimeType) + + part, err := writer.CreatePart(h) + if err != nil { + return nil, fmt.Errorf("create form part failed for image %d: %w", i, err) + } + + if _, err := io.Copy(part, file); err != nil { + return nil, fmt.Errorf("copy file failed for image %d: %w", i, err) + } + + // 复制完立即关闭,避免在循环内使用 defer 占用资源 + _ = file.Close() + } + + // Handle mask file if present + if maskFiles, exists := mf.File["mask"]; exists && len(maskFiles) > 0 { + maskFile, err := maskFiles[0].Open() + if err != nil { + return nil, errors.New("failed to open mask file") + } + // 复制完立即关闭,避免在循环内使用 defer 占用资源 + + // Determine MIME type for mask file + mimeType := detectImageMimeType(maskFiles[0].Filename) + + // Create a form file with the appropriate content type + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="mask"; filename="%s"`, maskFiles[0].Filename)) + h.Set("Content-Type", mimeType) + + maskPart, err := writer.CreatePart(h) + if err != nil { + return nil, errors.New("create form file failed for mask") + } + + if _, err := io.Copy(maskPart, maskFile); err != nil { + return nil, errors.New("copy mask file failed") + } + _ = maskFile.Close() + } + } else { + return nil, errors.New("no multipart form data found") + } + + // 关闭 multipart 编写器以设置分界线 + writer.Close() + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + return &requestBody, nil + + default: + return request, nil + } +} + +// detectImageMimeType determines the MIME type based on the file extension +func detectImageMimeType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".webp": + return "image/webp" + default: + // Try to detect from extension if possible + if strings.HasPrefix(ext, ".jp") { + return "image/jpeg" + } + // Default to png as a fallback + return "image/png" + } +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // 转换模型推理力度后缀 + effort, originModel := parseReasoningEffortFromModelSuffix(request.Model) + if effort != "" { + if request.Reasoning == nil { + request.Reasoning = &dto.Reasoning{ + Effort: effort, + } + } else { + request.Reasoning.Effort = effort + } + request.Model = originModel + } + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + if info.RelayMode == relayconstant.RelayModeAudioTranscription || + info.RelayMode == relayconstant.RelayModeAudioTranslation || + info.RelayMode == relayconstant.RelayModeImagesEdits { + return channel.DoFormRequest(a, c, info, requestBody) + } else if info.RelayMode == relayconstant.RelayModeRealtime { + return channel.DoWssRequest(a, c, info, requestBody) + } else { + return channel.DoApiRequest(a, c, info, requestBody) + } +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayMode { + case relayconstant.RelayModeRealtime: + err, usage = OpenaiRealtimeHandler(c, info) + case relayconstant.RelayModeAudioSpeech: + usage = OpenaiTTSHandler(c, resp, info) + case relayconstant.RelayModeAudioTranslation: + fallthrough + case relayconstant.RelayModeAudioTranscription: + err, usage = OpenaiSTTHandler(c, resp, info, a.ResponseFormat) + case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits: + usage, err = OpenaiHandlerWithUsage(c, info, resp) + case relayconstant.RelayModeRerank: + usage, err = common_handler.RerankHandler(c, info, resp) + case relayconstant.RelayModeResponses: + if info.IsStream { + usage, err = OaiResponsesStreamHandler(c, info, resp) + } else { + usage, err = OaiResponsesHandler(c, info, resp) + } + default: + if info.IsStream { + usage, err = OaiStreamHandler(c, info, resp) + } else { + usage, err = OpenaiHandler(c, info, resp) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + switch a.ChannelType { + case constant.ChannelType360: + return ai360.ModelList + case constant.ChannelTypeLingYiWanWu: + return lingyiwanwu.ModelList + //case constant.ChannelTypeMiniMax: + // return minimax.ModelList + case constant.ChannelTypeXinference: + return xinference.ModelList + case constant.ChannelTypeOpenRouter: + return openrouter.ModelList + default: + return ModelList + } +} + +func (a *Adaptor) GetChannelName() string { + switch a.ChannelType { + case constant.ChannelType360: + return ai360.ChannelName + case constant.ChannelTypeLingYiWanWu: + return lingyiwanwu.ChannelName + //case constant.ChannelTypeMiniMax: + // return minimax.ChannelName + case constant.ChannelTypeXinference: + return xinference.ChannelName + case constant.ChannelTypeOpenRouter: + return openrouter.ChannelName + default: + return ChannelName + } +} diff --git a/relay/channel/openai/constant.go b/relay/channel/openai/constant.go new file mode 100644 index 0000000000000000000000000000000000000000..af5b672485553d7706ed1cbcf0116b89ebb049e5 --- /dev/null +++ b/relay/channel/openai/constant.go @@ -0,0 +1,47 @@ +package openai + +var ModelList = []string{ + "gpt-3.5-turbo", "gpt-3.5-turbo-0613", "gpt-3.5-turbo-1106", "gpt-3.5-turbo-0125", + "gpt-3.5-turbo-16k", "gpt-3.5-turbo-16k-0613", + "gpt-3.5-turbo-instruct", + "gpt-4", "gpt-4-0613", "gpt-4-1106-preview", "gpt-4-0125-preview", + "gpt-4-32k", "gpt-4-32k-0613", + "gpt-4-turbo-preview", "gpt-4-turbo", "gpt-4-turbo-2024-04-09", + "gpt-4-vision-preview", + "chatgpt-4o-latest", + "gpt-4o", "gpt-4o-2024-05-13", "gpt-4o-2024-08-06", "gpt-4o-2024-11-20", + "gpt-4o-mini", "gpt-4o-mini-2024-07-18", + "gpt-4.5-preview", "gpt-4.5-preview-2025-02-27", + "gpt-4.1", "gpt-4.1-2025-04-14", + "gpt-4.1-mini", "gpt-4.1-mini-2025-04-14", + "gpt-4.1-nano", "gpt-4.1-nano-2025-04-14", + "o1", "o1-2024-12-17", + "o1-preview", "o1-preview-2024-09-12", + "o1-mini", "o1-mini-2024-09-12", + "o1-pro", "o1-pro-2025-03-19", + "o3-mini", "o3-mini-2025-01-31", + "o3-mini-high", "o3-mini-2025-01-31-high", + "o3-mini-low", "o3-mini-2025-01-31-low", + "o3-mini-medium", "o3-mini-2025-01-31-medium", + "o3", "o3-2025-04-16", + "o3-pro", "o3-pro-2025-06-10", + "o3-deep-research", "o3-deep-research-2025-06-26", + "o4-mini", "o4-mini-2025-04-16", + "o4-mini-deep-research", "o4-mini-deep-research-2025-06-26", + "gpt-5", "gpt-5-2025-08-07", "gpt-5-chat-latest", + "gpt-5-mini", "gpt-5-mini-2025-08-07", + "gpt-5-nano", "gpt-5-nano-2025-08-07", + "gpt-4o-audio-preview", "gpt-4o-audio-preview-2024-10-01", + "gpt-4o-realtime-preview", "gpt-4o-realtime-preview-2024-10-01", "gpt-4o-realtime-preview-2024-12-17", + "gpt-4o-mini-realtime-preview", "gpt-4o-mini-realtime-preview-2024-12-17", + "text-embedding-ada-002", "text-embedding-3-small", "text-embedding-3-large", + "text-curie-001", "text-babbage-001", "text-ada-001", + "text-moderation-latest", "text-moderation-stable", + "text-davinci-edit-001", + "davinci-002", "babbage-002", + "dall-e-3", "gpt-image-1", + "whisper-1", + "tts-1", "tts-1-1106", "tts-1-hd", "tts-1-hd-1106", +} + +var ChannelName = "openai" diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go new file mode 100644 index 0000000000000000000000000000000000000000..69731d4d21a33cc02c4c8827651d77295d8a096f --- /dev/null +++ b/relay/channel/openai/helper.go @@ -0,0 +1,261 @@ +package openai + +import ( + "encoding/json" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/samber/lo" + + "github.com/gin-gonic/gin" +) + +// 辅助函数 +func HandleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error { + info.SendResponseCount++ + + switch info.RelayFormat { + case types.RelayFormatOpenAI: + return sendStreamData(c, info, data, forceFormat, thinkToContent) + case types.RelayFormatClaude: + return handleClaudeFormat(c, data, info) + case types.RelayFormatGemini: + return handleGeminiFormat(c, data, info) + } + return nil +} + +func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error { + var streamResponse dto.ChatCompletionsStreamResponse + if err := common.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil { + return err + } + + if streamResponse.Usage != nil { + info.ClaudeConvertInfo.Usage = streamResponse.Usage + } + claudeResponses := service.StreamResponseOpenAI2Claude(&streamResponse, info) + for _, resp := range claudeResponses { + helper.ClaudeData(c, *resp) + } + return nil +} + +func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error { + var streamResponse dto.ChatCompletionsStreamResponse + if err := common.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil { + logger.LogError(c, "failed to unmarshal stream response: "+err.Error()) + return err + } + + geminiResponse := service.StreamResponseOpenAI2Gemini(&streamResponse, info) + + // 如果返回 nil,表示没有实际内容,跳过发送 + if geminiResponse == nil { + return nil + } + + geminiResponseStr, err := common.Marshal(geminiResponse) + if err != nil { + logger.LogError(c, "failed to marshal gemini response: "+err.Error()) + return err + } + + // send gemini format response + c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)}) + _ = helper.FlushWriter(c) + return nil +} + +func ProcessStreamResponse(streamResponse dto.ChatCompletionsStreamResponse, responseTextBuilder *strings.Builder, toolCount *int) error { + for _, choice := range streamResponse.Choices { + responseTextBuilder.WriteString(choice.Delta.GetContentString()) + responseTextBuilder.WriteString(choice.Delta.GetReasoningContent()) + if choice.Delta.ToolCalls != nil { + if len(choice.Delta.ToolCalls) > *toolCount { + *toolCount = len(choice.Delta.ToolCalls) + } + for _, tool := range choice.Delta.ToolCalls { + responseTextBuilder.WriteString(tool.Function.Name) + responseTextBuilder.WriteString(tool.Function.Arguments) + } + } + } + return nil +} + +func processTokens(relayMode int, streamItems []string, responseTextBuilder *strings.Builder, toolCount *int) error { + streamResp := "[" + strings.Join(streamItems, ",") + "]" + + switch relayMode { + case relayconstant.RelayModeChatCompletions: + return processChatCompletions(streamResp, streamItems, responseTextBuilder, toolCount) + case relayconstant.RelayModeCompletions: + return processCompletions(streamResp, streamItems, responseTextBuilder) + } + return nil +} + +func processChatCompletions(streamResp string, streamItems []string, responseTextBuilder *strings.Builder, toolCount *int) error { + var streamResponses []dto.ChatCompletionsStreamResponse + if err := json.Unmarshal(common.StringToByteSlice(streamResp), &streamResponses); err != nil { + // 一次性解析失败,逐个解析 + common.SysLog("error unmarshalling stream response: " + err.Error()) + for _, item := range streamItems { + var streamResponse dto.ChatCompletionsStreamResponse + if err := json.Unmarshal(common.StringToByteSlice(item), &streamResponse); err != nil { + return err + } + if err := ProcessStreamResponse(streamResponse, responseTextBuilder, toolCount); err != nil { + common.SysLog("error processing stream response: " + err.Error()) + } + } + return nil + } + + // 批量处理所有响应 + for _, streamResponse := range streamResponses { + for _, choice := range streamResponse.Choices { + responseTextBuilder.WriteString(choice.Delta.GetContentString()) + responseTextBuilder.WriteString(choice.Delta.GetReasoningContent()) + if choice.Delta.ToolCalls != nil { + if len(choice.Delta.ToolCalls) > *toolCount { + *toolCount = len(choice.Delta.ToolCalls) + } + for _, tool := range choice.Delta.ToolCalls { + responseTextBuilder.WriteString(tool.Function.Name) + responseTextBuilder.WriteString(tool.Function.Arguments) + } + } + } + } + return nil +} + +func processCompletions(streamResp string, streamItems []string, responseTextBuilder *strings.Builder) error { + var streamResponses []dto.CompletionsStreamResponse + if err := json.Unmarshal(common.StringToByteSlice(streamResp), &streamResponses); err != nil { + // 一次性解析失败,逐个解析 + common.SysLog("error unmarshalling stream response: " + err.Error()) + for _, item := range streamItems { + var streamResponse dto.CompletionsStreamResponse + if err := json.Unmarshal(common.StringToByteSlice(item), &streamResponse); err != nil { + continue + } + for _, choice := range streamResponse.Choices { + responseTextBuilder.WriteString(choice.Text) + } + } + return nil + } + + // 批量处理所有响应 + for _, streamResponse := range streamResponses { + for _, choice := range streamResponse.Choices { + responseTextBuilder.WriteString(choice.Text) + } + } + return nil +} + +func handleLastResponse(lastStreamData string, responseId *string, createAt *int64, + systemFingerprint *string, model *string, usage **dto.Usage, + containStreamUsage *bool, info *relaycommon.RelayInfo, + shouldSendLastResp *bool) error { + + var lastStreamResponse dto.ChatCompletionsStreamResponse + if err := json.Unmarshal(common.StringToByteSlice(lastStreamData), &lastStreamResponse); err != nil { + return err + } + + *responseId = lastStreamResponse.Id + *createAt = lastStreamResponse.Created + *systemFingerprint = lastStreamResponse.GetSystemFingerprint() + *model = lastStreamResponse.Model + + if service.ValidUsage(lastStreamResponse.Usage) { + *containStreamUsage = true + *usage = lastStreamResponse.Usage + if !info.ShouldIncludeUsage { + *shouldSendLastResp = lo.SomeBy(lastStreamResponse.Choices, func(choice dto.ChatCompletionsStreamResponseChoice) bool { + return choice.Delta.GetContentString() != "" || choice.Delta.GetReasoningContent() != "" + }) + } + } + + return nil +} + +func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStreamData string, + responseId string, createAt int64, model string, systemFingerprint string, + usage *dto.Usage, containStreamUsage bool) { + + switch info.RelayFormat { + case types.RelayFormatOpenAI: + if info.ShouldIncludeUsage && !containStreamUsage { + response := helper.GenerateFinalUsageResponse(responseId, createAt, model, *usage) + response.SetSystemFingerprint(systemFingerprint) + helper.ObjectData(c, response) + } + helper.Done(c) + + case types.RelayFormatClaude: + info.ClaudeConvertInfo.Done = true + var streamResponse dto.ChatCompletionsStreamResponse + if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil { + common.SysLog("error unmarshalling stream response: " + err.Error()) + return + } + + info.ClaudeConvertInfo.Usage = usage + + claudeResponses := service.StreamResponseOpenAI2Claude(&streamResponse, info) + for _, resp := range claudeResponses { + _ = helper.ClaudeData(c, *resp) + } + + case types.RelayFormatGemini: + var streamResponse dto.ChatCompletionsStreamResponse + if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil { + common.SysLog("error unmarshalling stream response: " + err.Error()) + return + } + + // 这里处理的是 openai 最后一个流响应,其 delta 为空,有 finish_reason 字段 + // 因此相比较于 google 官方的流响应,由 openai 转换而来会多一个 parts 为空,finishReason 为 STOP 的响应 + // 而包含最后一段文本输出的响应(倒数第二个)的 finishReason 为 null + // 暂不知是否有程序会不兼容。 + + geminiResponse := service.StreamResponseOpenAI2Gemini(&streamResponse, info) + + // openai 流响应开头的空数据 + if geminiResponse == nil { + return + } + + geminiResponseStr, err := common.Marshal(geminiResponse) + if err != nil { + common.SysLog("error marshalling gemini response: " + err.Error()) + return + } + + // 发送最终的 Gemini 响应 + c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)}) + _ = helper.FlushWriter(c) + } +} + +func sendResponsesStreamData(c *gin.Context, streamResponse dto.ResponsesStreamResponse, data string) { + if data == "" { + return + } + helper.ResponseChunkData(c, streamResponse, data) +} diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go new file mode 100644 index 0000000000000000000000000000000000000000..eafb11d9982c151dbaa7cb33cb81cb5d6f49dd10 --- /dev/null +++ b/relay/channel/openai/relay-openai.go @@ -0,0 +1,704 @@ +package openai + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/relay/channel/openrouter" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + + "github.com/QuantumNous/new-api/types" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error { + if data == "" { + return nil + } + + if !forceFormat && !thinkToContent { + return helper.StringData(c, data) + } + + var lastStreamResponse dto.ChatCompletionsStreamResponse + if err := common.UnmarshalJsonStr(data, &lastStreamResponse); err != nil { + return err + } + + if !thinkToContent { + return helper.ObjectData(c, lastStreamResponse) + } + + hasThinkingContent := false + hasContent := false + var thinkingContent strings.Builder + for _, choice := range lastStreamResponse.Choices { + if len(choice.Delta.GetReasoningContent()) > 0 { + hasThinkingContent = true + thinkingContent.WriteString(choice.Delta.GetReasoningContent()) + } + if len(choice.Delta.GetContentString()) > 0 { + hasContent = true + } + } + + // Handle think to content conversion + if info.ThinkingContentInfo.IsFirstThinkingContent { + if hasThinkingContent { + response := lastStreamResponse.Copy() + for i := range response.Choices { + // send `think` tag with thinking content + response.Choices[i].Delta.SetContentString("\n" + thinkingContent.String()) + response.Choices[i].Delta.ReasoningContent = nil + response.Choices[i].Delta.Reasoning = nil + } + info.ThinkingContentInfo.IsFirstThinkingContent = false + info.ThinkingContentInfo.HasSentThinkingContent = true + return helper.ObjectData(c, response) + } + } + + if lastStreamResponse.Choices == nil || len(lastStreamResponse.Choices) == 0 { + return helper.ObjectData(c, lastStreamResponse) + } + + // Process each choice + for i, choice := range lastStreamResponse.Choices { + // Handle transition from thinking to content + // only send `` tag when previous thinking content has been sent + if hasContent && !info.ThinkingContentInfo.SendLastThinkingContent && info.ThinkingContentInfo.HasSentThinkingContent { + response := lastStreamResponse.Copy() + for j := range response.Choices { + response.Choices[j].Delta.SetContentString("\n\n") + response.Choices[j].Delta.ReasoningContent = nil + response.Choices[j].Delta.Reasoning = nil + } + info.ThinkingContentInfo.SendLastThinkingContent = true + helper.ObjectData(c, response) + } + + // Convert reasoning content to regular content if any + if len(choice.Delta.GetReasoningContent()) > 0 { + lastStreamResponse.Choices[i].Delta.SetContentString(choice.Delta.GetReasoningContent()) + lastStreamResponse.Choices[i].Delta.ReasoningContent = nil + lastStreamResponse.Choices[i].Delta.Reasoning = nil + } else if !hasThinkingContent && !hasContent { + // flush thinking content + lastStreamResponse.Choices[i].Delta.ReasoningContent = nil + lastStreamResponse.Choices[i].Delta.Reasoning = nil + } + } + + return helper.ObjectData(c, lastStreamResponse) +} + +func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + if resp == nil || resp.Body == nil { + logger.LogError(c, "invalid response or response body") + return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + + defer service.CloseResponseBodyGracefully(resp) + + model := info.UpstreamModelName + var responseId string + var createAt int64 = 0 + var systemFingerprint string + var containStreamUsage bool + var responseTextBuilder strings.Builder + var toolCount int + var usage = &dto.Usage{} + var streamItems []string // store stream items + var lastStreamData string + var secondLastStreamData string // 存储倒数第二个stream data,用于音频模型 + + // 检查是否为音频模型 + isAudioModel := strings.Contains(strings.ToLower(model), "audio") + + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + if lastStreamData != "" { + err := HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent) + if err != nil { + common.SysLog("error handling stream format: " + err.Error()) + } + } + if len(data) > 0 { + // 对音频模型,保存倒数第二个stream data + if isAudioModel && lastStreamData != "" { + secondLastStreamData = lastStreamData + } + + lastStreamData = data + streamItems = append(streamItems, data) + } + return true + }) + + // 对音频模型,从倒数第二个stream data中提取usage信息 + if isAudioModel && secondLastStreamData != "" { + var streamResp struct { + Usage *dto.Usage `json:"usage"` + } + err := json.Unmarshal([]byte(secondLastStreamData), &streamResp) + if err == nil && streamResp.Usage != nil && service.ValidUsage(streamResp.Usage) { + usage = streamResp.Usage + containStreamUsage = true + + if common.DebugEnabled { + logger.LogDebug(c, fmt.Sprintf("Audio model usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d", + usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens, + usage.InputTokens, usage.OutputTokens)) + } + } + } + + // 处理最后的响应 + shouldSendLastResp := true + if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage, + &containStreamUsage, info, &shouldSendLastResp); err != nil { + logger.LogError(c, fmt.Sprintf("error handling last response: %s, lastStreamData: [%s]", err.Error(), lastStreamData)) + } + + if info.RelayFormat == types.RelayFormatOpenAI { + if shouldSendLastResp { + _ = sendStreamData(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent) + } + } + + // 处理token计算 + if err := processTokens(info.RelayMode, streamItems, &responseTextBuilder, &toolCount); err != nil { + logger.LogError(c, "error processing tokens: "+err.Error()) + } + + if !containStreamUsage { + usage = service.ResponseText2Usage(c, responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens) + usage.CompletionTokens += toolCount * 7 + } + + applyUsagePostProcessing(info, usage, nil) + + HandleFinalResponse(c, info, lastStreamData, responseId, createAt, model, systemFingerprint, usage, containStreamUsage) + + return usage, nil +} + +func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer service.CloseResponseBodyGracefully(resp) + + var simpleResponse dto.OpenAITextResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + if common.DebugEnabled { + println("upstream response body:", string(responseBody)) + } + // Unmarshal to simpleResponse + if info.ChannelType == constant.ChannelTypeOpenRouter && info.ChannelOtherSettings.IsOpenRouterEnterprise() { + // 尝试解析为 openrouter enterprise + var enterpriseResponse openrouter.OpenRouterEnterpriseResponse + err = common.Unmarshal(responseBody, &enterpriseResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if enterpriseResponse.Success { + responseBody = enterpriseResponse.Data + } else { + logger.LogError(c, fmt.Sprintf("openrouter enterprise response success=false, data: %s", enterpriseResponse.Data)) + return nil, types.NewOpenAIError(fmt.Errorf("openrouter response success=false"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + } + + err = common.Unmarshal(responseBody, &simpleResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + } + + forceFormat := false + if info.ChannelSetting.ForceFormat { + forceFormat = true + } + + usageModified := false + if simpleResponse.Usage.PromptTokens == 0 { + completionTokens := simpleResponse.Usage.CompletionTokens + if completionTokens == 0 { + for _, choice := range simpleResponse.Choices { + ctkm := service.CountTextToken(choice.Message.StringContent()+choice.Message.ReasoningContent+choice.Message.Reasoning, info.UpstreamModelName) + completionTokens += ctkm + } + } + simpleResponse.Usage = dto.Usage{ + PromptTokens: info.PromptTokens, + CompletionTokens: completionTokens, + TotalTokens: info.PromptTokens + completionTokens, + } + usageModified = true + } + + applyUsagePostProcessing(info, &simpleResponse.Usage, responseBody) + + switch info.RelayFormat { + case types.RelayFormatOpenAI: + if usageModified { + var bodyMap map[string]interface{} + err = common.Unmarshal(responseBody, &bodyMap) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + bodyMap["usage"] = simpleResponse.Usage + responseBody, _ = common.Marshal(bodyMap) + } + if forceFormat { + responseBody, err = common.Marshal(simpleResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + } else { + break + } + case types.RelayFormatClaude: + claudeResp := service.ResponseOpenAI2Claude(&simpleResponse, info) + claudeRespStr, err := common.Marshal(claudeResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + responseBody = claudeRespStr + case types.RelayFormatGemini: + geminiResp := service.ResponseOpenAI2Gemini(&simpleResponse, info) + geminiRespStr, err := common.Marshal(geminiResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + responseBody = geminiRespStr + } + + service.IOCopyBytesGracefully(c, resp, responseBody) + + return &simpleResponse.Usage, nil +} + +func streamTTSResponse(c *gin.Context, resp *http.Response) { + c.Writer.WriteHeaderNow() + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + logger.LogWarn(c, "streaming not supported") + _, err := io.Copy(c.Writer, resp.Body) + if err != nil { + logger.LogWarn(c, err.Error()) + } + return + } + + buffer := make([]byte, 4096) + for { + n, err := resp.Body.Read(buffer) + //logger.LogInfo(c, fmt.Sprintf("streamTTSResponse read %d bytes", n)) + if n > 0 { + if _, writeErr := c.Writer.Write(buffer[:n]); writeErr != nil { + logger.LogError(c, writeErr.Error()) + break + } + flusher.Flush() + } + if err != nil { + if err != io.EOF { + logger.LogError(c, err.Error()) + } + break + } + } +} + +func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) *dto.Usage { + // the status code has been judged before, if there is a body reading failure, + // it should be regarded as a non-recoverable error, so it should not return err for external retry. + // Analogous to nginx's load balancing, it will only retry if it can't be requested or + // if the upstream returns a specific status code, once the upstream has already written the header, + // the subsequent failure of the response body should be regarded as a non-recoverable error, + // and can be terminated directly. + defer service.CloseResponseBodyGracefully(resp) + usage := &dto.Usage{} + usage.PromptTokens = info.PromptTokens + usage.TotalTokens = info.PromptTokens + for k, v := range resp.Header { + c.Writer.Header().Set(k, v[0]) + } + c.Writer.WriteHeader(resp.StatusCode) + + isStreaming := resp.ContentLength == -1 || resp.Header.Get("Content-Length") == "" + if isStreaming { + streamTTSResponse(c, resp) + } else { + c.Writer.WriteHeaderNow() + _, err := io.Copy(c.Writer, resp.Body) + if err != nil { + logger.LogError(c, err.Error()) + } + } + return usage +} + +func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, responseFormat string) (*types.NewAPIError, *dto.Usage) { + defer service.CloseResponseBodyGracefully(resp) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil + } + // 写入新的 response body + service.IOCopyBytesGracefully(c, resp, responseBody) + + var responseData struct { + Usage *dto.Usage `json:"usage"` + } + if err := json.Unmarshal(responseBody, &responseData); err == nil && responseData.Usage != nil { + if responseData.Usage.TotalTokens > 0 { + usage := responseData.Usage + if usage.PromptTokens == 0 { + usage.PromptTokens = usage.InputTokens + } + if usage.CompletionTokens == 0 { + usage.CompletionTokens = usage.OutputTokens + } + return nil, usage + } + } + + usage := &dto.Usage{} + usage.PromptTokens = info.PromptTokens + usage.CompletionTokens = 0 + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + return nil, usage +} + +func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.RealtimeUsage) { + if info == nil || info.ClientWs == nil || info.TargetWs == nil { + return types.NewError(fmt.Errorf("invalid websocket connection"), types.ErrorCodeBadResponse), nil + } + + info.IsStream = true + clientConn := info.ClientWs + targetConn := info.TargetWs + + clientClosed := make(chan struct{}) + targetClosed := make(chan struct{}) + sendChan := make(chan []byte, 100) + receiveChan := make(chan []byte, 100) + errChan := make(chan error, 2) + + usage := &dto.RealtimeUsage{} + localUsage := &dto.RealtimeUsage{} + sumUsage := &dto.RealtimeUsage{} + + gopool.Go(func() { + defer func() { + if r := recover(); r != nil { + errChan <- fmt.Errorf("panic in client reader: %v", r) + } + }() + for { + select { + case <-c.Done(): + return + default: + _, message, err := clientConn.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + errChan <- fmt.Errorf("error reading from client: %v", err) + } + close(clientClosed) + return + } + + realtimeEvent := &dto.RealtimeEvent{} + err = common.Unmarshal(message, realtimeEvent) + if err != nil { + errChan <- fmt.Errorf("error unmarshalling message: %v", err) + return + } + + if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdate { + if realtimeEvent.Session != nil { + if realtimeEvent.Session.Tools != nil { + info.RealtimeTools = realtimeEvent.Session.Tools + } + } + } + + textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName) + if err != nil { + errChan <- fmt.Errorf("error counting text token: %v", err) + return + } + logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken)) + localUsage.TotalTokens += textToken + audioToken + localUsage.InputTokens += textToken + audioToken + localUsage.InputTokenDetails.TextTokens += textToken + localUsage.InputTokenDetails.AudioTokens += audioToken + + err = helper.WssString(c, targetConn, string(message)) + if err != nil { + errChan <- fmt.Errorf("error writing to target: %v", err) + return + } + + select { + case sendChan <- message: + default: + } + } + } + }) + + gopool.Go(func() { + defer func() { + if r := recover(); r != nil { + errChan <- fmt.Errorf("panic in target reader: %v", r) + } + }() + for { + select { + case <-c.Done(): + return + default: + _, message, err := targetConn.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + errChan <- fmt.Errorf("error reading from target: %v", err) + } + close(targetClosed) + return + } + info.SetFirstResponseTime() + realtimeEvent := &dto.RealtimeEvent{} + err = common.Unmarshal(message, realtimeEvent) + if err != nil { + errChan <- fmt.Errorf("error unmarshalling message: %v", err) + return + } + + if realtimeEvent.Type == dto.RealtimeEventTypeResponseDone { + realtimeUsage := realtimeEvent.Response.Usage + if realtimeUsage != nil { + usage.TotalTokens += realtimeUsage.TotalTokens + usage.InputTokens += realtimeUsage.InputTokens + usage.OutputTokens += realtimeUsage.OutputTokens + usage.InputTokenDetails.AudioTokens += realtimeUsage.InputTokenDetails.AudioTokens + usage.InputTokenDetails.CachedTokens += realtimeUsage.InputTokenDetails.CachedTokens + usage.InputTokenDetails.TextTokens += realtimeUsage.InputTokenDetails.TextTokens + usage.OutputTokenDetails.AudioTokens += realtimeUsage.OutputTokenDetails.AudioTokens + usage.OutputTokenDetails.TextTokens += realtimeUsage.OutputTokenDetails.TextTokens + err := preConsumeUsage(c, info, usage, sumUsage) + if err != nil { + errChan <- fmt.Errorf("error consume usage: %v", err) + return + } + // 本次计费完成,清除 + usage = &dto.RealtimeUsage{} + + localUsage = &dto.RealtimeUsage{} + } else { + textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName) + if err != nil { + errChan <- fmt.Errorf("error counting text token: %v", err) + return + } + logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken)) + localUsage.TotalTokens += textToken + audioToken + info.IsFirstRequest = false + localUsage.InputTokens += textToken + audioToken + localUsage.InputTokenDetails.TextTokens += textToken + localUsage.InputTokenDetails.AudioTokens += audioToken + err = preConsumeUsage(c, info, localUsage, sumUsage) + if err != nil { + errChan <- fmt.Errorf("error consume usage: %v", err) + return + } + // 本次计费完成,清除 + localUsage = &dto.RealtimeUsage{} + // print now usage + } + logger.LogInfo(c, fmt.Sprintf("realtime streaming sumUsage: %v", sumUsage)) + logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage)) + logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage)) + + } else if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdated || realtimeEvent.Type == dto.RealtimeEventTypeSessionCreated { + realtimeSession := realtimeEvent.Session + if realtimeSession != nil { + // update audio format + info.InputAudioFormat = common.GetStringIfEmpty(realtimeSession.InputAudioFormat, info.InputAudioFormat) + info.OutputAudioFormat = common.GetStringIfEmpty(realtimeSession.OutputAudioFormat, info.OutputAudioFormat) + } + } else { + textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName) + if err != nil { + errChan <- fmt.Errorf("error counting text token: %v", err) + return + } + logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken)) + localUsage.TotalTokens += textToken + audioToken + localUsage.OutputTokens += textToken + audioToken + localUsage.OutputTokenDetails.TextTokens += textToken + localUsage.OutputTokenDetails.AudioTokens += audioToken + } + + err = helper.WssString(c, clientConn, string(message)) + if err != nil { + errChan <- fmt.Errorf("error writing to client: %v", err) + return + } + + select { + case receiveChan <- message: + default: + } + } + } + }) + + select { + case <-clientClosed: + case <-targetClosed: + case err := <-errChan: + //return service.OpenAIErrorWrapper(err, "realtime_error", http.StatusInternalServerError), nil + logger.LogError(c, "realtime error: "+err.Error()) + case <-c.Done(): + } + + if usage.TotalTokens != 0 { + _ = preConsumeUsage(c, info, usage, sumUsage) + } + + if localUsage.TotalTokens != 0 { + _ = preConsumeUsage(c, info, localUsage, sumUsage) + } + + // check usage total tokens, if 0, use local usage + + return nil, sumUsage +} + +func preConsumeUsage(ctx *gin.Context, info *relaycommon.RelayInfo, usage *dto.RealtimeUsage, totalUsage *dto.RealtimeUsage) error { + if usage == nil || totalUsage == nil { + return fmt.Errorf("invalid usage pointer") + } + + totalUsage.TotalTokens += usage.TotalTokens + totalUsage.InputTokens += usage.InputTokens + totalUsage.OutputTokens += usage.OutputTokens + totalUsage.InputTokenDetails.CachedTokens += usage.InputTokenDetails.CachedTokens + totalUsage.InputTokenDetails.TextTokens += usage.InputTokenDetails.TextTokens + totalUsage.InputTokenDetails.AudioTokens += usage.InputTokenDetails.AudioTokens + totalUsage.OutputTokenDetails.TextTokens += usage.OutputTokenDetails.TextTokens + totalUsage.OutputTokenDetails.AudioTokens += usage.OutputTokenDetails.AudioTokens + // clear usage + err := service.PreWssConsumeQuota(ctx, info, usage) + return err +} + +func OpenaiHandlerWithUsage(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer service.CloseResponseBodyGracefully(resp) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + + var usageResp dto.SimpleResponse + err = common.Unmarshal(responseBody, &usageResp) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + // 写入新的 response body + service.IOCopyBytesGracefully(c, resp, responseBody) + + // Once we've written to the client, we should not return errors anymore + // because the upstream has already consumed resources and returned content + // We should still perform billing even if parsing fails + // format + if usageResp.InputTokens > 0 { + usageResp.PromptTokens += usageResp.InputTokens + } + if usageResp.OutputTokens > 0 { + usageResp.CompletionTokens += usageResp.OutputTokens + } + if usageResp.InputTokensDetails != nil { + usageResp.PromptTokensDetails.ImageTokens += usageResp.InputTokensDetails.ImageTokens + usageResp.PromptTokensDetails.TextTokens += usageResp.InputTokensDetails.TextTokens + } + applyUsagePostProcessing(info, &usageResp.Usage, responseBody) + return &usageResp.Usage, nil +} + +func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, responseBody []byte) { + if info == nil || usage == nil { + return + } + + switch info.ChannelType { + case constant.ChannelTypeDeepSeek: + if usage.PromptTokensDetails.CachedTokens == 0 && usage.PromptCacheHitTokens != 0 { + usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens + } + case constant.ChannelTypeZhipu_v4: + if usage.PromptTokensDetails.CachedTokens == 0 { + if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 { + usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens + } else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok { + usage.PromptTokensDetails.CachedTokens = cachedTokens + } else if usage.PromptCacheHitTokens > 0 { + usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens + } + } + } +} + +func extractCachedTokensFromBody(body []byte) (int, bool) { + if len(body) == 0 { + return 0, false + } + + var payload struct { + Usage struct { + PromptTokensDetails struct { + CachedTokens *int `json:"cached_tokens"` + } `json:"prompt_tokens_details"` + CachedTokens *int `json:"cached_tokens"` + PromptCacheHitTokens *int `json:"prompt_cache_hit_tokens"` + } `json:"usage"` + } + + if err := json.Unmarshal(body, &payload); err != nil { + return 0, false + } + + if payload.Usage.PromptTokensDetails.CachedTokens != nil { + return *payload.Usage.PromptTokensDetails.CachedTokens, true + } + if payload.Usage.CachedTokens != nil { + return *payload.Usage.CachedTokens, true + } + if payload.Usage.PromptCacheHitTokens != nil { + return *payload.Usage.PromptCacheHitTokens, true + } + return 0, false +} diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go new file mode 100644 index 0000000000000000000000000000000000000000..3f8eb69a8ac06b103ba87f97b5b30292d10209b8 --- /dev/null +++ b/relay/channel/openai/relay_responses.go @@ -0,0 +1,150 @@ +package openai + +import ( + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer service.CloseResponseBodyGracefully(resp) + + // read response body + var responsesResponse dto.OpenAIResponsesResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + err = common.Unmarshal(responseBody, &responsesResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if oaiError := responsesResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + } + + if responsesResponse.HasImageGenerationCall() { + c.Set("image_generation_call", true) + c.Set("image_generation_call_quality", responsesResponse.GetQuality()) + c.Set("image_generation_call_size", responsesResponse.GetSize()) + } + + // 写入新的 response body + service.IOCopyBytesGracefully(c, resp, responseBody) + + // compute usage + usage := dto.Usage{} + if responsesResponse.Usage != nil { + usage.PromptTokens = responsesResponse.Usage.InputTokens + usage.CompletionTokens = responsesResponse.Usage.OutputTokens + usage.TotalTokens = responsesResponse.Usage.TotalTokens + if responsesResponse.Usage.InputTokensDetails != nil { + usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens + } + } + if info == nil || info.ResponsesUsageInfo == nil || info.ResponsesUsageInfo.BuiltInTools == nil { + return &usage, nil + } + // 解析 Tools 用量 + for _, tool := range responsesResponse.Tools { + buildToolinfo, ok := info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])] + if !ok || buildToolinfo == nil { + logger.LogError(c, fmt.Sprintf("BuiltInTools not found for tool type: %v", tool["type"])) + continue + } + buildToolinfo.CallCount++ + } + return &usage, nil +} + +func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + if resp == nil || resp.Body == nil { + logger.LogError(c, "invalid response or response body") + return nil, types.NewError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse) + } + + defer service.CloseResponseBodyGracefully(resp) + + var usage = &dto.Usage{} + var responseTextBuilder strings.Builder + + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + + // 检查当前数据是否包含 completed 状态和 usage 信息 + var streamResponse dto.ResponsesStreamResponse + if err := common.UnmarshalJsonStr(data, &streamResponse); err == nil { + sendResponsesStreamData(c, streamResponse, data) + switch streamResponse.Type { + case "response.completed": + if streamResponse.Response != nil { + if streamResponse.Response.Usage != nil { + if streamResponse.Response.Usage.InputTokens != 0 { + usage.PromptTokens = streamResponse.Response.Usage.InputTokens + } + if streamResponse.Response.Usage.OutputTokens != 0 { + usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens + } + if streamResponse.Response.Usage.TotalTokens != 0 { + usage.TotalTokens = streamResponse.Response.Usage.TotalTokens + } + if streamResponse.Response.Usage.InputTokensDetails != nil { + usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens + } + } + if streamResponse.Response.HasImageGenerationCall() { + c.Set("image_generation_call", true) + c.Set("image_generation_call_quality", streamResponse.Response.GetQuality()) + c.Set("image_generation_call_size", streamResponse.Response.GetSize()) + } + } + case "response.output_text.delta": + // 处理输出文本 + responseTextBuilder.WriteString(streamResponse.Delta) + case dto.ResponsesOutputTypeItemDone: + // 函数调用处理 + if streamResponse.Item != nil { + switch streamResponse.Item.Type { + case dto.BuildInCallWebSearchCall: + if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil { + if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil { + webSearchTool.CallCount++ + } + } + } + } + } + } else { + logger.LogError(c, "failed to unmarshal stream response: "+err.Error()) + } + return true + }) + + if usage.CompletionTokens == 0 { + // 计算输出文本的 token 数量 + tempStr := responseTextBuilder.String() + if len(tempStr) > 0 { + // 非正常结束,使用输出文本的 token 数量 + completionTokens := service.CountTextToken(tempStr, info.UpstreamModelName) + usage.CompletionTokens = completionTokens + } + } + + if usage.PromptTokens == 0 && usage.CompletionTokens != 0 { + usage.PromptTokens = info.PromptTokens + } + + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + + return usage, nil +} diff --git a/relay/channel/openrouter/constant.go b/relay/channel/openrouter/constant.go new file mode 100644 index 0000000000000000000000000000000000000000..0372eb9a2c8e876f6b8937cef1668fe3ace8895d --- /dev/null +++ b/relay/channel/openrouter/constant.go @@ -0,0 +1,5 @@ +package openrouter + +var ModelList = []string{} + +var ChannelName = "openrouter" diff --git a/relay/channel/openrouter/dto.go b/relay/channel/openrouter/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..a324998525e813247b62c9df763d610be8b8cee4 --- /dev/null +++ b/relay/channel/openrouter/dto.go @@ -0,0 +1,16 @@ +package openrouter + +import "encoding/json" + +type RequestReasoning struct { + // One of the following (not both): + Effort string `json:"effort,omitempty"` // Can be "high", "medium", or "low" (OpenAI-style) + MaxTokens int `json:"max_tokens,omitempty"` // Specific token limit (Anthropic-style) + // Optional: Default is false. All models support this. + Exclude bool `json:"exclude,omitempty"` // Set to true to exclude reasoning tokens from response +} + +type OpenRouterEnterpriseResponse struct { + Data json.RawMessage `json:"data"` + Success bool `json:"success"` +} diff --git a/relay/channel/palm/adaptor.go b/relay/channel/palm/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..3ae900981d0734e752f6c6cbe224c77a777119c7 --- /dev/null +++ b/relay/channel/palm/adaptor.go @@ -0,0 +1,97 @@ +package palm + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/v1beta2/models/chat-bison-001:generateMessage", info.ChannelBaseUrl), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("x-goog-api-key", info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + var responseText string + err, responseText = palmStreamHandler(c, resp) + usage = service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens) + } else { + usage, err = palmHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/palm/constants.go b/relay/channel/palm/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..b5c881bf19d1990a3d19b87feb4e639352d9598f --- /dev/null +++ b/relay/channel/palm/constants.go @@ -0,0 +1,7 @@ +package palm + +var ModelList = []string{ + "PaLM-2", +} + +var ChannelName = "google palm" diff --git a/relay/channel/palm/dto.go b/relay/channel/palm/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..47ca3fc667afe810f31eabee65916fb59c602a59 --- /dev/null +++ b/relay/channel/palm/dto.go @@ -0,0 +1,38 @@ +package palm + +import "github.com/QuantumNous/new-api/dto" + +type PaLMChatMessage struct { + Author string `json:"author"` + Content string `json:"content"` +} + +type PaLMFilter struct { + Reason string `json:"reason"` + Message string `json:"message"` +} + +type PaLMPrompt struct { + Messages []PaLMChatMessage `json:"messages"` +} + +type PaLMChatRequest struct { + Prompt PaLMPrompt `json:"prompt"` + Temperature *float64 `json:"temperature,omitempty"` + CandidateCount int `json:"candidateCount,omitempty"` + TopP float64 `json:"topP,omitempty"` + TopK uint `json:"topK,omitempty"` +} + +type PaLMError struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` +} + +type PaLMChatResponse struct { + Candidates []PaLMChatMessage `json:"candidates"` + Messages []dto.Message `json:"messages"` + Filters []PaLMFilter `json:"filters"` + Error PaLMError `json:"error"` +} diff --git a/relay/channel/palm/relay-palm.go b/relay/channel/palm/relay-palm.go new file mode 100644 index 0000000000000000000000000000000000000000..abfb92c0e13f438f6d71b7a9fd494afcc7a1b9a6 --- /dev/null +++ b/relay/channel/palm/relay-palm.go @@ -0,0 +1,139 @@ +package palm + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +// https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#request-body +// https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#response-body + +func responsePaLM2OpenAI(response *PaLMChatResponse) *dto.OpenAITextResponse { + fullTextResponse := dto.OpenAITextResponse{ + Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), + } + for i, candidate := range response.Candidates { + choice := dto.OpenAITextResponseChoice{ + Index: i, + Message: dto.Message{ + Role: "assistant", + Content: candidate.Content, + }, + FinishReason: "stop", + } + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + } + return &fullTextResponse +} + +func streamResponsePaLM2OpenAI(palmResponse *PaLMChatResponse) *dto.ChatCompletionsStreamResponse { + var choice dto.ChatCompletionsStreamResponseChoice + if len(palmResponse.Candidates) > 0 { + choice.Delta.SetContentString(palmResponse.Candidates[0].Content) + } + choice.FinishReason = &constant.FinishReasonStop + var response dto.ChatCompletionsStreamResponse + response.Object = "chat.completion.chunk" + response.Model = "palm2" + response.Choices = []dto.ChatCompletionsStreamResponseChoice{choice} + return &response +} + +func palmStreamHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, string) { + responseText := "" + responseId := helper.GetResponseID(c) + createdTime := common.GetTimestamp() + dataChan := make(chan string) + stopChan := make(chan bool) + go func() { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + common.SysLog("error reading stream response: " + err.Error()) + stopChan <- true + return + } + service.CloseResponseBodyGracefully(resp) + var palmResponse PaLMChatResponse + err = json.Unmarshal(responseBody, &palmResponse) + if err != nil { + common.SysLog("error unmarshalling stream response: " + err.Error()) + stopChan <- true + return + } + fullTextResponse := streamResponsePaLM2OpenAI(&palmResponse) + fullTextResponse.Id = responseId + fullTextResponse.Created = createdTime + if len(palmResponse.Candidates) > 0 { + responseText = palmResponse.Candidates[0].Content + } + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + common.SysLog("error marshalling stream response: " + err.Error()) + stopChan <- true + return + } + dataChan <- string(jsonResponse) + stopChan <- true + }() + helper.SetEventStreamHeaders(c) + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + c.Render(-1, common.CustomEvent{Data: "data: " + data}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + service.CloseResponseBodyGracefully(resp) + return nil, responseText +} + +func palmHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + var palmResponse PaLMChatResponse + err = json.Unmarshal(responseBody, &palmResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if palmResponse.Error.Code != 0 || len(palmResponse.Candidates) == 0 { + return nil, types.WithOpenAIError(types.OpenAIError{ + Message: palmResponse.Error.Message, + Type: palmResponse.Error.Status, + Param: "", + Code: palmResponse.Error.Code, + }, resp.StatusCode) + } + fullTextResponse := responsePaLM2OpenAI(&palmResponse) + completionTokens := service.CountTextToken(palmResponse.Candidates[0].Content, info.UpstreamModelName) + usage := dto.Usage{ + PromptTokens: info.PromptTokens, + CompletionTokens: completionTokens, + TotalTokens: info.PromptTokens + completionTokens, + } + fullTextResponse.Usage = usage + jsonResponse, err := common.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + service.IOCopyBytesGracefully(c, resp, jsonResponse) + return &usage, nil +} diff --git a/relay/channel/perplexity/adaptor.go b/relay/channel/perplexity/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..43e39b12e1d95abdf077a5142bb97b2501495063 --- /dev/null +++ b/relay/channel/perplexity/adaptor.go @@ -0,0 +1,94 @@ +package perplexity + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := openai.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/chat/completions", info.ChannelBaseUrl), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if request.TopP >= 1 { + request.TopP = 0.99 + } + return requestOpenAI2Perplexity(*request), nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + adaptor := openai.Adaptor{} + usage, err = adaptor.DoResponse(c, resp, info) + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/perplexity/constants.go b/relay/channel/perplexity/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..d37c3b8779f607ebbe7f341b6d74fd29094bd836 --- /dev/null +++ b/relay/channel/perplexity/constants.go @@ -0,0 +1,8 @@ +package perplexity + +var ModelList = []string{ + "llama-3-sonar-small-32k-chat", "llama-3-sonar-small-32k-online", "llama-3-sonar-large-32k-chat", "llama-3-sonar-large-32k-online", "llama-3-8b-instruct", "llama-3-70b-instruct", "mixtral-8x7b-instruct", + "sonar", "sonar-pro", "sonar-reasoning", +} + +var ChannelName = "perplexity" diff --git a/relay/channel/perplexity/relay-perplexity.go b/relay/channel/perplexity/relay-perplexity.go new file mode 100644 index 0000000000000000000000000000000000000000..b07bed68aa3b6ed6fd16c7d272d379fbd3b434c9 --- /dev/null +++ b/relay/channel/perplexity/relay-perplexity.go @@ -0,0 +1,28 @@ +package perplexity + +import "github.com/QuantumNous/new-api/dto" + +func requestOpenAI2Perplexity(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest { + messages := make([]dto.Message, 0, len(request.Messages)) + for _, message := range request.Messages { + messages = append(messages, dto.Message{ + Role: message.Role, + Content: message.Content, + }) + } + return &dto.GeneralOpenAIRequest{ + Model: request.Model, + Stream: request.Stream, + Messages: messages, + Temperature: request.Temperature, + TopP: request.TopP, + MaxTokens: request.GetMaxTokens(), + FrequencyPenalty: request.FrequencyPenalty, + PresencePenalty: request.PresencePenalty, + SearchDomainFilter: request.SearchDomainFilter, + SearchRecencyFilter: request.SearchRecencyFilter, + ReturnImages: request.ReturnImages, + ReturnRelatedQuestions: request.ReturnRelatedQuestions, + SearchMode: request.SearchMode, + } +} diff --git a/relay/channel/replicate/adaptor.go b/relay/channel/replicate/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..9ee521615990a42bd07fad7c3b0dadb2abd66a7c --- /dev/null +++ b/relay/channel/replicate/adaptor.go @@ -0,0 +1,530 @@ +package replicate + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info == nil { + return "", errors.New("replicate adaptor: relay info is nil") + } + if info.ChannelBaseUrl == "" { + info.ChannelBaseUrl = constant.ChannelBaseURLs[constant.ChannelTypeReplicate] + } + requestPath := info.RequestURLPath + if requestPath == "" { + return info.ChannelBaseUrl, nil + } + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, requestPath, info.ChannelType), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + if info == nil { + return errors.New("replicate adaptor: relay info is nil") + } + if info.ApiKey == "" { + return errors.New("replicate adaptor: api key is required") + } + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + req.Set("Prefer", "wait") + if req.Get("Content-Type") == "" { + req.Set("Content-Type", "application/json") + } + if req.Get("Accept") == "" { + req.Set("Accept", "application/json") + } + return nil +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + if info == nil { + return nil, errors.New("replicate adaptor: relay info is nil") + } + if strings.TrimSpace(request.Prompt) == "" { + if v := c.PostForm("prompt"); strings.TrimSpace(v) != "" { + request.Prompt = v + } + } + if strings.TrimSpace(request.Prompt) == "" { + return nil, errors.New("replicate adaptor: prompt is required") + } + + modelName := strings.TrimSpace(info.UpstreamModelName) + if modelName == "" { + modelName = strings.TrimSpace(request.Model) + } + if modelName == "" { + modelName = ModelFlux11Pro + } + info.UpstreamModelName = modelName + + info.RequestURLPath = fmt.Sprintf("/v1/models/%s/predictions", modelName) + + inputPayload := make(map[string]any) + inputPayload["prompt"] = request.Prompt + + if size := strings.TrimSpace(request.Size); size != "" { + if aspect, width, height, ok := mapOpenAISizeToFlux(size); ok { + if aspect != "" { + if aspect == "custom" { + inputPayload["aspect_ratio"] = "custom" + if width > 0 { + inputPayload["width"] = width + } + if height > 0 { + inputPayload["height"] = height + } + } else { + inputPayload["aspect_ratio"] = aspect + } + } + } + } + + if len(request.OutputFormat) > 0 { + var outputFormat string + if err := json.Unmarshal(request.OutputFormat, &outputFormat); err == nil && strings.TrimSpace(outputFormat) != "" { + inputPayload["output_format"] = outputFormat + } + } + + if request.N > 0 { + inputPayload["num_outputs"] = int(request.N) + } + + if strings.EqualFold(request.Quality, "hd") || strings.EqualFold(request.Quality, "high") { + inputPayload["prompt_upsampling"] = true + } + + if info.RelayMode == relayconstant.RelayModeImagesEdits { + imageURL, err := uploadFileFromForm(c, info, "image", "image[]", "image_prompt") + if err != nil { + return nil, err + } + if imageURL == "" { + return nil, errors.New("replicate adaptor: image file is required for edits") + } + inputPayload["image_prompt"] = imageURL + } + + if len(request.ExtraFields) > 0 { + var extra map[string]any + if err := common.Unmarshal(request.ExtraFields, &extra); err != nil { + return nil, fmt.Errorf("replicate adaptor: failed to decode extra_fields: %w", err) + } + for key, val := range extra { + inputPayload[key] = val + } + } + + for key, raw := range request.Extra { + if strings.EqualFold(key, "input") { + var extraInput map[string]any + if err := common.Unmarshal(raw, &extraInput); err != nil { + return nil, fmt.Errorf("replicate adaptor: failed to decode extra input: %w", err) + } + for k, v := range extraInput { + inputPayload[k] = v + } + continue + } + if raw == nil { + continue + } + var val any + if err := common.Unmarshal(raw, &val); err != nil { + return nil, fmt.Errorf("replicate adaptor: failed to decode extra field %s: %w", key, err) + } + inputPayload[key] = val + } + + return map[string]any{ + "input": inputPayload, + }, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (any, *types.NewAPIError) { + if resp == nil { + return nil, types.NewError(errors.New("replicate adaptor: empty response"), types.ErrorCodeBadResponse) + } + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + } + _ = resp.Body.Close() + + var prediction PredictionResponse + if err := common.Unmarshal(responseBody, &prediction); err != nil { + return nil, types.NewError(fmt.Errorf("replicate adaptor: failed to decode response: %w", err), types.ErrorCodeBadResponseBody) + } + + if prediction.Error != nil { + errMsg := prediction.Error.Message + if errMsg == "" { + errMsg = prediction.Error.Detail + } + if errMsg == "" { + errMsg = prediction.Error.Code + } + if errMsg == "" { + errMsg = "replicate adaptor: prediction error" + } + return nil, types.NewError(errors.New(errMsg), types.ErrorCodeBadResponse) + } + + if prediction.Status != "" && !strings.EqualFold(prediction.Status, "succeeded") { + return nil, types.NewError(fmt.Errorf("replicate adaptor: prediction status %q", prediction.Status), types.ErrorCodeBadResponse) + } + + var urls []string + + appendOutput := func(value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + urls = append(urls, value) + } + + switch output := prediction.Output.(type) { + case string: + appendOutput(output) + case []any: + for _, item := range output { + if str, ok := item.(string); ok { + appendOutput(str) + } + } + case nil: + // no output + default: + if str, ok := output.(fmt.Stringer); ok { + appendOutput(str.String()) + } + } + + if len(urls) == 0 { + return nil, types.NewError(errors.New("replicate adaptor: empty prediction output"), types.ErrorCodeBadResponseBody) + } + + var imageReq *dto.ImageRequest + if info != nil { + if req, ok := info.Request.(*dto.ImageRequest); ok { + imageReq = req + } + } + + wantsBase64 := imageReq != nil && strings.EqualFold(imageReq.ResponseFormat, "b64_json") + + imageResponse := dto.ImageResponse{ + Created: common.GetTimestamp(), + Data: make([]dto.ImageData, 0), + } + + if wantsBase64 { + converted, convErr := downloadImagesToBase64(urls) + if convErr != nil { + return nil, types.NewError(convErr, types.ErrorCodeBadResponse) + } + for _, content := range converted { + if content == "" { + continue + } + imageResponse.Data = append(imageResponse.Data, dto.ImageData{B64Json: content}) + } + } else { + for _, url := range urls { + if url == "" { + continue + } + imageResponse.Data = append(imageResponse.Data, dto.ImageData{Url: url}) + } + } + + if len(imageResponse.Data) == 0 { + return nil, types.NewError(errors.New("replicate adaptor: no usable image data"), types.ErrorCodeBadResponse) + } + + responseBytes, err := common.Marshal(imageResponse) + if err != nil { + return nil, types.NewError(fmt.Errorf("replicate adaptor: encode response failed: %w", err), types.ErrorCodeBadResponseBody) + } + + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(http.StatusOK) + _, _ = c.Writer.Write(responseBytes) + + usage := &dto.Usage{} + return usage, nil +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +func downloadImagesToBase64(urls []string) ([]string, error) { + results := make([]string, 0, len(urls)) + for _, url := range urls { + if strings.TrimSpace(url) == "" { + continue + } + _, data, err := service.GetImageFromUrl(url) + if err != nil { + return nil, fmt.Errorf("replicate adaptor: failed to download image from %s: %w", url, err) + } + results = append(results, data) + } + return results, nil +} + +func mapOpenAISizeToFlux(size string) (aspect string, width int, height int, ok bool) { + parts := strings.Split(size, "x") + if len(parts) != 2 { + return "", 0, 0, false + } + w, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) + h, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) + if err1 != nil || err2 != nil || w <= 0 || h <= 0 { + return "", 0, 0, false + } + + switch { + case w == h: + return "1:1", 0, 0, true + case w == 1792 && h == 1024: + return "16:9", 0, 0, true + case w == 1024 && h == 1792: + return "9:16", 0, 0, true + case w == 1536 && h == 1024: + return "3:2", 0, 0, true + case w == 1024 && h == 1536: + return "2:3", 0, 0, true + } + + rw, rh := reduceRatio(w, h) + ratioStr := fmt.Sprintf("%d:%d", rw, rh) + switch ratioStr { + case "1:1", "16:9", "9:16", "3:2", "2:3", "4:5", "5:4", "3:4", "4:3": + return ratioStr, 0, 0, true + } + + width = normalizeFluxDimension(w) + height = normalizeFluxDimension(h) + return "custom", width, height, true +} + +func reduceRatio(w, h int) (int, int) { + g := gcd(w, h) + if g == 0 { + return w, h + } + return w / g, h / g +} + +func gcd(a, b int) int { + for b != 0 { + a, b = b, a%b + } + if a < 0 { + return -a + } + return a +} + +func normalizeFluxDimension(value int) int { + const ( + minDim = 256 + maxDim = 1440 + step = 32 + ) + if value < minDim { + value = minDim + } + if value > maxDim { + value = maxDim + } + remainder := value % step + if remainder != 0 { + if remainder >= step/2 { + value += step - remainder + } else { + value -= remainder + } + } + if value < minDim { + value = minDim + } + if value > maxDim { + value = maxDim + } + return value +} + +func uploadFileFromForm(c *gin.Context, info *relaycommon.RelayInfo, fieldCandidates ...string) (string, error) { + if info == nil { + return "", errors.New("replicate adaptor: relay info is nil") + } + + mf := c.Request.MultipartForm + if mf == nil { + if _, err := c.MultipartForm(); err != nil { + return "", fmt.Errorf("replicate adaptor: parse multipart form failed: %w", err) + } + mf = c.Request.MultipartForm + } + if mf == nil || len(mf.File) == 0 { + return "", nil + } + + if len(fieldCandidates) == 0 { + fieldCandidates = []string{"image", "image[]", "image_prompt"} + } + + var fileHeader *multipart.FileHeader + for _, key := range fieldCandidates { + if files := mf.File[key]; len(files) > 0 { + fileHeader = files[0] + break + } + } + if fileHeader == nil { + for _, files := range mf.File { + if len(files) > 0 { + fileHeader = files[0] + break + } + } + } + if fileHeader == nil { + return "", nil + } + + file, err := fileHeader.Open() + if err != nil { + return "", fmt.Errorf("replicate adaptor: failed to open image file: %w", err) + } + defer file.Close() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + hdr := make(textproto.MIMEHeader) + hdr.Set("Content-Disposition", fmt.Sprintf("form-data; name=\"content\"; filename=\"%s\"", fileHeader.Filename)) + contentType := fileHeader.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + hdr.Set("Content-Type", contentType) + + part, err := writer.CreatePart(hdr) + if err != nil { + writer.Close() + return "", fmt.Errorf("replicate adaptor: create upload form failed: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + writer.Close() + return "", fmt.Errorf("replicate adaptor: copy image content failed: %w", err) + } + formContentType := writer.FormDataContentType() + writer.Close() + + baseURL := info.ChannelBaseUrl + if baseURL == "" { + baseURL = constant.ChannelBaseURLs[constant.ChannelTypeReplicate] + } + uploadURL := relaycommon.GetFullRequestURL(baseURL, "/v1/files", info.ChannelType) + + req, err := http.NewRequest(http.MethodPost, uploadURL, &body) + if err != nil { + return "", fmt.Errorf("replicate adaptor: create upload request failed: %w", err) + } + req.Header.Set("Content-Type", formContentType) + req.Header.Set("Authorization", "Bearer "+info.ApiKey) + + resp, err := service.GetHttpClient().Do(req) + if err != nil { + return "", fmt.Errorf("replicate adaptor: upload image failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("replicate adaptor: read upload response failed: %w", err) + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("replicate adaptor: upload image failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + + var uploadResp FileUploadResponse + if err := common.Unmarshal(respBody, &uploadResp); err != nil { + return "", fmt.Errorf("replicate adaptor: decode upload response failed: %w", err) + } + if uploadResp.Urls.Get == "" { + return "", errors.New("replicate adaptor: upload response missing url") + } + return uploadResp.Urls.Get, nil +} + +func (a *Adaptor) ConvertOpenAIRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeneralOpenAIRequest) (any, error) { + return nil, errors.New("replicate adaptor: ConvertOpenAIRequest is not implemented") +} + +func (a *Adaptor) ConvertRerankRequest(*gin.Context, int, dto.RerankRequest) (any, error) { + return nil, errors.New("replicate adaptor: ConvertRerankRequest is not implemented") +} + +func (a *Adaptor) ConvertEmbeddingRequest(*gin.Context, *relaycommon.RelayInfo, dto.EmbeddingRequest) (any, error) { + return nil, errors.New("replicate adaptor: ConvertEmbeddingRequest is not implemented") +} + +func (a *Adaptor) ConvertAudioRequest(*gin.Context, *relaycommon.RelayInfo, dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("replicate adaptor: ConvertAudioRequest is not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(*gin.Context, *relaycommon.RelayInfo, dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("replicate adaptor: ConvertOpenAIResponsesRequest is not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + return nil, errors.New("replicate adaptor: ConvertClaudeRequest is not implemented") +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + return nil, errors.New("replicate adaptor: ConvertGeminiRequest is not implemented") +} diff --git a/relay/channel/replicate/constants.go b/relay/channel/replicate/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..b047bcfadc5094564b6a1f4ca9a89d6356c7c76d --- /dev/null +++ b/relay/channel/replicate/constants.go @@ -0,0 +1,12 @@ +package replicate + +const ( + // ChannelName identifies the replicate channel. + ChannelName = "replicate" + // ModelFlux11Pro is the default image generation model supported by this channel. + ModelFlux11Pro = "black-forest-labs/flux-1.1-pro" +) + +var ModelList = []string{ + ModelFlux11Pro, +} diff --git a/relay/channel/replicate/dto.go b/relay/channel/replicate/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..2ff06dcab7410b28c6478bbe5e4c429402174214 --- /dev/null +++ b/relay/channel/replicate/dto.go @@ -0,0 +1,19 @@ +package replicate + +type PredictionResponse struct { + Status string `json:"status"` + Output any `json:"output"` + Error *PredictionError `json:"error"` +} + +type PredictionError struct { + Code string `json:"code"` + Message string `json:"message"` + Detail string `json:"detail"` +} + +type FileUploadResponse struct { + Urls struct { + Get string `json:"get"` + } `json:"urls"` +} diff --git a/relay/channel/siliconflow/adaptor.go b/relay/channel/siliconflow/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..02a82edfcc5accb369b3c12f5c4963bf616f1586 --- /dev/null +++ b/relay/channel/siliconflow/adaptor.go @@ -0,0 +1,127 @@ +package siliconflow + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := openai.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + adaptor := openai.Adaptor{} + return adaptor.ConvertAudioRequest(c, info, request) +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + // 解析extra到SFImageRequest里,以填入SiliconFlow特殊字段。若失败重建一个空的。 + sfRequest := &SFImageRequest{} + extra, err := common.Marshal(request.Extra) + if err == nil { + err = common.Unmarshal(extra, sfRequest) + if err != nil { + sfRequest = &SFImageRequest{} + } + } + + sfRequest.Model = request.Model + sfRequest.Prompt = request.Prompt + // 优先使用image_size/batch_size,否则使用OpenAI标准的size/n + if sfRequest.ImageSize == "" { + sfRequest.ImageSize = request.Size + } + if sfRequest.BatchSize == 0 { + sfRequest.BatchSize = request.N + } + + return sfRequest, nil +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.RelayMode == constant.RelayModeRerank { + return fmt.Sprintf("%s/v1/rerank", info.ChannelBaseUrl), nil + } + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + // SiliconFlow requires messages array for FIM requests, even if client doesn't send it + if (request.Prefix != nil || request.Suffix != nil) && len(request.Messages) == 0 { + // Add an empty user message to satisfy SiliconFlow's requirement + request.Messages = []dto.Message{ + { + Role: "user", + Content: "", + }, + } + } + return request, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + adaptor := openai.Adaptor{} + return adaptor.DoRequest(c, info, requestBody) +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayMode { + case constant.RelayModeRerank: + usage, err = siliconflowRerankHandler(c, info, resp) + default: + adaptor := openai.Adaptor{} + usage, err = adaptor.DoResponse(c, resp, info) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/siliconflow/constant.go b/relay/channel/siliconflow/constant.go new file mode 100644 index 0000000000000000000000000000000000000000..fea6fcd4896b1e6e52e5c4029af8ec472a533c81 --- /dev/null +++ b/relay/channel/siliconflow/constant.go @@ -0,0 +1,51 @@ +package siliconflow + +var ModelList = []string{ + "THUDM/glm-4-9b-chat", + //"stabilityai/stable-diffusion-xl-base-1.0", + //"TencentARC/PhotoMaker", + "InstantX/InstantID", + //"stabilityai/stable-diffusion-2-1", + //"stabilityai/sd-turbo", + //"stabilityai/sdxl-turbo", + "ByteDance/SDXL-Lightning", + "deepseek-ai/deepseek-llm-67b-chat", + "Qwen/Qwen1.5-14B-Chat", + "Qwen/Qwen1.5-7B-Chat", + "Qwen/Qwen1.5-110B-Chat", + "Qwen/Qwen1.5-32B-Chat", + "01-ai/Yi-1.5-6B-Chat", + "01-ai/Yi-1.5-9B-Chat-16K", + "01-ai/Yi-1.5-34B-Chat-16K", + "THUDM/chatglm3-6b", + "deepseek-ai/DeepSeek-V2-Chat", + "Qwen/Qwen2-72B-Instruct", + "Qwen/Qwen2-7B-Instruct", + "Qwen/Qwen2-57B-A14B-Instruct", + //"stabilityai/stable-diffusion-3-medium", + "deepseek-ai/DeepSeek-Coder-V2-Instruct", + "Qwen/Qwen2-1.5B-Instruct", + "internlm/internlm2_5-7b-chat", + "BAAI/bge-large-en-v1.5", + "BAAI/bge-large-zh-v1.5", + "Pro/Qwen/Qwen2-7B-Instruct", + "Pro/Qwen/Qwen2-1.5B-Instruct", + "Pro/Qwen/Qwen1.5-7B-Chat", + "Pro/THUDM/glm-4-9b-chat", + "Pro/THUDM/chatglm3-6b", + "Pro/01-ai/Yi-1.5-9B-Chat-16K", + "Pro/01-ai/Yi-1.5-6B-Chat", + "Pro/google/gemma-2-9b-it", + "Pro/internlm/internlm2_5-7b-chat", + "Pro/meta-llama/Meta-Llama-3-8B-Instruct", + "Pro/mistralai/Mistral-7B-Instruct-v0.2", + "black-forest-labs/FLUX.1-schnell", + "FunAudioLLM/SenseVoiceSmall", + "netease-youdao/bce-embedding-base_v1", + "BAAI/bge-m3", + "internlm/internlm2_5-20b-chat", + "Qwen/Qwen2-Math-72B-Instruct", + "netease-youdao/bce-reranker-base_v1", + "BAAI/bge-reranker-v2-m3", +} +var ChannelName = "siliconflow" diff --git a/relay/channel/siliconflow/dto.go b/relay/channel/siliconflow/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..1009751074a8fc9005c1dceb34b952d2d4b4a34b --- /dev/null +++ b/relay/channel/siliconflow/dto.go @@ -0,0 +1,32 @@ +package siliconflow + +import "github.com/QuantumNous/new-api/dto" + +type SFTokens struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type SFMeta struct { + Tokens SFTokens `json:"tokens"` +} + +type SFRerankResponse struct { + Results []dto.RerankResponseResult `json:"results"` + Meta SFMeta `json:"meta"` +} + +type SFImageRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + NegativePrompt string `json:"negative_prompt,omitempty"` + ImageSize string `json:"image_size,omitempty"` + BatchSize uint `json:"batch_size,omitempty"` + Seed uint64 `json:"seed,omitempty"` + NumInferenceSteps uint `json:"num_inference_steps,omitempty"` + GuidanceScale float64 `json:"guidance_scale,omitempty"` + Cfg float64 `json:"cfg,omitempty"` + Image string `json:"image,omitempty"` + Image2 string `json:"image2,omitempty"` + Image3 string `json:"image3,omitempty"` +} diff --git a/relay/channel/siliconflow/relay-siliconflow.go b/relay/channel/siliconflow/relay-siliconflow.go new file mode 100644 index 0000000000000000000000000000000000000000..421731fb1a96686725bce5f3ec8618912546d8cf --- /dev/null +++ b/relay/channel/siliconflow/relay-siliconflow.go @@ -0,0 +1,45 @@ +package siliconflow + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func siliconflowRerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + var siliconflowResp SFRerankResponse + err = json.Unmarshal(responseBody, &siliconflowResp) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + usage := &dto.Usage{ + PromptTokens: siliconflowResp.Meta.Tokens.InputTokens, + CompletionTokens: siliconflowResp.Meta.Tokens.OutputTokens, + TotalTokens: siliconflowResp.Meta.Tokens.InputTokens + siliconflowResp.Meta.Tokens.OutputTokens, + } + rerankResp := &dto.RerankResponse{ + Results: siliconflowResp.Results, + Usage: *usage, + } + + jsonResponse, err := json.Marshal(rerankResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + service.IOCopyBytesGracefully(c, resp, jsonResponse) + return usage, nil +} diff --git a/relay/channel/submodel/adaptor.go b/relay/channel/submodel/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..58b2a3b298599355916579d66ee35531a905c141 --- /dev/null +++ b/relay/channel/submodel/adaptor.go @@ -0,0 +1,87 @@ +package submodel + +import ( + "errors" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/submodel/constants.go b/relay/channel/submodel/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..72d6fee3176d21b1733dab575db4095136dd9dbc --- /dev/null +++ b/relay/channel/submodel/constants.go @@ -0,0 +1,16 @@ +package submodel + +var ModelList = []string{ + "NousResearch/Hermes-4-405B-FP8", + "Qwen/Qwen3-235B-A22B-Thinking-2507", + "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", + "Qwen/Qwen3-235B-A22B-Instruct-2507", + "zai-org/GLM-4.5-FP8", + "openai/gpt-oss-120b", + "deepseek-ai/DeepSeek-R1-0528", + "deepseek-ai/DeepSeek-R1", + "deepseek-ai/DeepSeek-V3-0324", + "deepseek-ai/DeepSeek-V3.1", +} + +const ChannelName = "submodel" diff --git a/relay/channel/task/ali/adaptor.go b/relay/channel/task/ali/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..32d5da3980e56379b435bfbfa675796d5a38c82d --- /dev/null +++ b/relay/channel/task/ali/adaptor.go @@ -0,0 +1,505 @@ +package ali + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/samber/lo" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" +) + +// ============================ +// Request / Response structures +// ============================ + +// AliVideoRequest 阿里通义万相视频生成请求 +type AliVideoRequest struct { + Model string `json:"model"` + Input AliVideoInput `json:"input"` + Parameters *AliVideoParameters `json:"parameters,omitempty"` +} + +// AliVideoInput 视频输入参数 +type AliVideoInput struct { + Prompt string `json:"prompt,omitempty"` // 文本提示词 + ImgURL string `json:"img_url,omitempty"` // 首帧图像URL或Base64(图生视频) + FirstFrameURL string `json:"first_frame_url,omitempty"` // 首帧图片URL(首尾帧生视频) + LastFrameURL string `json:"last_frame_url,omitempty"` // 尾帧图片URL(首尾帧生视频) + AudioURL string `json:"audio_url,omitempty"` // 音频URL(wan2.5支持) + NegativePrompt string `json:"negative_prompt,omitempty"` // 反向提示词 + Template string `json:"template,omitempty"` // 视频特效模板 +} + +// AliVideoParameters 视频参数 +type AliVideoParameters struct { + Resolution string `json:"resolution,omitempty"` // 分辨率: 480P/720P/1080P(图生视频、首尾帧生视频) + Size string `json:"size,omitempty"` // 尺寸: 如 "832*480"(文生视频) + Duration int `json:"duration,omitempty"` // 时长: 3-10秒 + PromptExtend bool `json:"prompt_extend,omitempty"` // 是否开启prompt智能改写 + Watermark bool `json:"watermark,omitempty"` // 是否添加水印 + Audio *bool `json:"audio,omitempty"` // 是否添加音频(wan2.5) + Seed int `json:"seed,omitempty"` // 随机数种子 +} + +// AliVideoResponse 阿里通义万相响应 +type AliVideoResponse struct { + Output AliVideoOutput `json:"output"` + RequestID string `json:"request_id"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Usage *AliUsage `json:"usage,omitempty"` +} + +// AliVideoOutput 输出信息 +type AliVideoOutput struct { + TaskID string `json:"task_id"` + TaskStatus string `json:"task_status"` + SubmitTime string `json:"submit_time,omitempty"` + ScheduledTime string `json:"scheduled_time,omitempty"` + EndTime string `json:"end_time,omitempty"` + OrigPrompt string `json:"orig_prompt,omitempty"` + ActualPrompt string `json:"actual_prompt,omitempty"` + VideoURL string `json:"video_url,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// AliUsage 使用统计 +type AliUsage struct { + Duration int `json:"duration,omitempty"` + VideoCount int `json:"video_count,omitempty"` + SR int `json:"SR,omitempty"` +} + +type AliMetadata struct { + // Input 相关 + AudioURL string `json:"audio_url,omitempty"` // 音频URL + ImgURL string `json:"img_url,omitempty"` // 图片URL(图生视频) + FirstFrameURL string `json:"first_frame_url,omitempty"` // 首帧图片URL(首尾帧生视频) + LastFrameURL string `json:"last_frame_url,omitempty"` // 尾帧图片URL(首尾帧生视频) + NegativePrompt string `json:"negative_prompt,omitempty"` // 反向提示词 + Template string `json:"template,omitempty"` // 视频特效模板 + + // Parameters 相关 + Resolution *string `json:"resolution,omitempty"` // 分辨率: 480P/720P/1080P + Size *string `json:"size,omitempty"` // 尺寸: 如 "832*480" + Duration *int `json:"duration,omitempty"` // 时长 + PromptExtend *bool `json:"prompt_extend,omitempty"` // 是否开启prompt智能改写 + Watermark *bool `json:"watermark,omitempty"` // 是否添加水印 + Audio *bool `json:"audio,omitempty"` // 是否添加音频 + Seed *int `json:"seed,omitempty"` // 随机数种子 +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + apiKey string + baseURL string + aliReq *AliVideoRequest +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + a.apiKey = info.ApiKey +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + // 阿里通义万相支持 JSON 格式,不使用 multipart + var taskReq relaycommon.TaskSubmitReq + if err := common.UnmarshalBodyReusable(c, &taskReq); err != nil { + return service.TaskErrorWrapper(err, "unmarshal_task_request_failed", http.StatusBadRequest) + } + aliReq, err := a.convertToAliRequest(info, taskReq) + if err != nil { + return service.TaskErrorWrapper(err, "convert_to_ali_request_failed", http.StatusInternalServerError) + } + a.aliReq = aliReq + logger.LogJson(c, "ali video request body", aliReq) + return relaycommon.ValidateMultipartDirect(c, info) +} + +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/api/v1/services/aigc/video-generation/video-synthesis", a.baseURL), nil +} + +// BuildRequestHeader sets required headers for Ali API +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Authorization", "Bearer "+a.apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-DashScope-Async", "enable") // 阿里异步任务必须设置 + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + bodyBytes, err := common.Marshal(a.aliReq) + if err != nil { + return nil, errors.Wrap(err, "marshal_ali_request_failed") + } + + return bytes.NewReader(bodyBytes), nil +} + +var ( + size480p = []string{ + "832*480", + "480*832", + "624*624", + } + size720p = []string{ + "1280*720", + "720*1280", + "960*960", + "1088*832", + "832*1088", + } + size1080p = []string{ + "1920*1080", + "1080*1920", + "1440*1440", + "1632*1248", + "1248*1632", + } +) + +func sizeToResolution(size string) (string, error) { + if lo.Contains(size480p, size) { + return "480P", nil + } else if lo.Contains(size720p, size) { + return "720P", nil + } else if lo.Contains(size1080p, size) { + return "1080P", nil + } + return "", fmt.Errorf("invalid size: %s", size) +} + +func ProcessAliOtherRatios(aliReq *AliVideoRequest) (map[string]float64, error) { + otherRatios := make(map[string]float64) + aliRatios := map[string]map[string]float64{ + "wan2.5-t2v-preview": { + "480P": 1, + "720P": 2, + "1080P": 1 / 0.3, + }, + "wan2.2-t2v-plus": { + "480P": 1, + "1080P": 0.7 / 0.14, + }, + "wan2.5-i2v-preview": { + "480P": 1, + "720P": 2, + "1080P": 1 / 0.3, + }, + "wan2.2-i2v-plus": { + "480P": 1, + "1080P": 0.7 / 0.14, + }, + "wan2.2-kf2v-flash": { + "480P": 1, + "720P": 2, + "1080P": 4.8, + }, + "wan2.2-i2v-flash": { + "480P": 1, + "720P": 2, + }, + "wan2.2-s2v": { + "480P": 1, + "720P": 0.9 / 0.5, + }, + } + var resolution string + + // size match + if aliReq.Parameters.Size != "" { + toResolution, err := sizeToResolution(aliReq.Parameters.Size) + if err != nil { + return nil, err + } + resolution = toResolution + } else { + resolution = strings.ToUpper(aliReq.Parameters.Resolution) + if !strings.HasSuffix(resolution, "P") { + resolution = resolution + "P" + } + } + if otherRatio, ok := aliRatios[aliReq.Model]; ok { + if ratio, ok := otherRatio[resolution]; ok { + otherRatios[fmt.Sprintf("resolution-%s", resolution)] = ratio + } + } + return otherRatios, nil +} + +func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relaycommon.TaskSubmitReq) (*AliVideoRequest, error) { + aliReq := &AliVideoRequest{ + Model: req.Model, + Input: AliVideoInput{ + Prompt: req.Prompt, + ImgURL: req.InputReference, + }, + Parameters: &AliVideoParameters{ + PromptExtend: true, // 默认开启智能改写 + Watermark: false, + }, + } + + // 处理分辨率映射 + if req.Size != "" { + // text to video size must be contained * + if strings.Contains(req.Model, "t2v") && !strings.Contains(req.Size, "*") { + return nil, fmt.Errorf("invalid size: %s, example: %s", req.Size, "1920*1080") + } + if strings.Contains(req.Size, "*") { + aliReq.Parameters.Size = req.Size + } else { + resolution := strings.ToUpper(req.Size) + // 支持 480p, 720p, 1080p 或 480P, 720P, 1080P + if !strings.HasSuffix(resolution, "P") { + resolution = resolution + "P" + } + aliReq.Parameters.Resolution = resolution + } + } else { + // 根据模型设置默认分辨率 + if strings.Contains(req.Model, "t2v") { // image to video + if strings.HasPrefix(req.Model, "wan2.5") { + aliReq.Parameters.Size = "1920*1080" + } else if strings.HasPrefix(req.Model, "wan2.2") { + aliReq.Parameters.Size = "1920*1080" + } else { + aliReq.Parameters.Size = "1280*720" + } + } else { + if strings.HasPrefix(req.Model, "wan2.5") { + aliReq.Parameters.Resolution = "1080P" + } else if strings.HasPrefix(req.Model, "wan2.2-i2v-flash") { + aliReq.Parameters.Resolution = "720P" + } else if strings.HasPrefix(req.Model, "wan2.2-i2v-plus") { + aliReq.Parameters.Resolution = "1080P" + } else { + aliReq.Parameters.Resolution = "720P" + } + } + } + + // 处理时长 + if req.Duration > 0 { + aliReq.Parameters.Duration = req.Duration + } else if req.Seconds != "" { + seconds, err := strconv.Atoi(req.Seconds) + if err != nil { + return nil, errors.Wrap(err, "convert seconds to int failed") + } else { + aliReq.Parameters.Duration = seconds + } + } else { + aliReq.Parameters.Duration = 5 // 默认5秒 + } + + // 从 metadata 中提取额外参数 + if req.Metadata != nil { + if metadataBytes, err := common.Marshal(req.Metadata); err == nil { + err = common.Unmarshal(metadataBytes, aliReq) + if err != nil { + return nil, errors.Wrap(err, "unmarshal metadata failed") + } + } else { + return nil, errors.Wrap(err, "marshal metadata failed") + } + } + + if aliReq.Model != req.Model { + return nil, errors.New("can't change model with metadata") + } + + info.PriceData.OtherRatios = map[string]float64{ + "seconds": float64(aliReq.Parameters.Duration), + } + + ratios, err := ProcessAliOtherRatios(aliReq) + if err != nil { + return nil, err + } + for s, f := range ratios { + info.PriceData.OtherRatios[s] = f + } + + return aliReq, nil +} + +// DoRequest delegates to common helper +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +// DoResponse handles upstream response +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + _ = resp.Body.Close() + + // 解析阿里响应 + var aliResp AliVideoResponse + if err := common.Unmarshal(responseBody, &aliResp); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + + // 检查错误 + if aliResp.Code != "" { + taskErr = service.TaskErrorWrapper(fmt.Errorf("%s: %s", aliResp.Code, aliResp.Message), "ali_api_error", resp.StatusCode) + return + } + + if aliResp.Output.TaskID == "" { + taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError) + return + } + + // 转换为 OpenAI 格式响应 + openAIResp := dto.NewOpenAIVideo() + openAIResp.ID = aliResp.Output.TaskID + openAIResp.Model = c.GetString("model") + if openAIResp.Model == "" && info != nil { + openAIResp.Model = info.OriginModelName + } + openAIResp.Status = convertAliStatus(aliResp.Output.TaskStatus) + openAIResp.CreatedAt = common.GetTimestamp() + + // 返回 OpenAI 格式 + c.JSON(http.StatusOK, openAIResp) + + return aliResp.Output.TaskID, responseBody, nil +} + +// FetchTask 查询任务状态 +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + + uri := fmt.Sprintf("%s/api/v1/tasks/%s", baseUrl, taskID) + + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+key) + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return ModelList +} + +func (a *TaskAdaptor) GetChannelName() string { + return ChannelName +} + +// ParseTaskResult 解析任务结果 +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + var aliResp AliVideoResponse + if err := common.Unmarshal(respBody, &aliResp); err != nil { + return nil, errors.Wrap(err, "unmarshal task result failed") + } + + taskResult := relaycommon.TaskInfo{ + Code: 0, + } + + // 状态映射 + switch aliResp.Output.TaskStatus { + case "PENDING": + taskResult.Status = model.TaskStatusQueued + case "RUNNING": + taskResult.Status = model.TaskStatusInProgress + case "SUCCEEDED": + taskResult.Status = model.TaskStatusSuccess + // 阿里直接返回视频URL,不需要额外的代理端点 + taskResult.Url = aliResp.Output.VideoURL + case "FAILED", "CANCELED", "UNKNOWN": + taskResult.Status = model.TaskStatusFailure + if aliResp.Message != "" { + taskResult.Reason = aliResp.Message + } else if aliResp.Output.Message != "" { + taskResult.Reason = fmt.Sprintf("task failed, code: %s , message: %s", aliResp.Output.Code, aliResp.Output.Message) + } else { + taskResult.Reason = "task failed" + } + default: + taskResult.Status = model.TaskStatusQueued + } + + return &taskResult, nil +} + +func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) { + var aliResp AliVideoResponse + if err := common.Unmarshal(task.Data, &aliResp); err != nil { + return nil, errors.Wrap(err, "unmarshal ali response failed") + } + + openAIResp := dto.NewOpenAIVideo() + openAIResp.ID = task.TaskID + openAIResp.Status = convertAliStatus(aliResp.Output.TaskStatus) + openAIResp.Model = task.Properties.OriginModelName + openAIResp.SetProgressStr(task.Progress) + openAIResp.CreatedAt = task.CreatedAt + openAIResp.CompletedAt = task.UpdatedAt + + // 设置视频URL(核心字段) + openAIResp.SetMetadata("url", aliResp.Output.VideoURL) + + // 错误处理 + if aliResp.Code != "" { + openAIResp.Error = &dto.OpenAIVideoError{ + Code: aliResp.Code, + Message: aliResp.Message, + } + } else if aliResp.Output.Code != "" { + openAIResp.Error = &dto.OpenAIVideoError{ + Code: aliResp.Output.Code, + Message: aliResp.Output.Message, + } + } + + return common.Marshal(openAIResp) +} + +func convertAliStatus(aliStatus string) string { + switch aliStatus { + case "PENDING": + return dto.VideoStatusQueued + case "RUNNING": + return dto.VideoStatusInProgress + case "SUCCEEDED": + return dto.VideoStatusCompleted + case "FAILED", "CANCELED", "UNKNOWN": + return dto.VideoStatusFailed + default: + return dto.VideoStatusUnknown + } +} diff --git a/relay/channel/task/ali/constants.go b/relay/channel/task/ali/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..8dc64ec597bdca57ccea17aadcb75b96d71365e1 --- /dev/null +++ b/relay/channel/task/ali/constants.go @@ -0,0 +1,11 @@ +package ali + +var ModelList = []string{ + "wan2.5-i2v-preview", // 万相2.5 preview(有声视频)推荐 + "wan2.2-i2v-flash", // 万相2.2极速版(无声视频) + "wan2.2-i2v-plus", // 万相2.2专业版(无声视频) + "wanx2.1-i2v-plus", // 万相2.1专业版(无声视频) + "wanx2.1-i2v-turbo", // 万相2.1极速版(无声视频) +} + +var ChannelName = "ali" diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..1bacb201922f3939f9d8328a58db0090e4eb0827 --- /dev/null +++ b/relay/channel/task/doubao/adaptor.go @@ -0,0 +1,249 @@ +package doubao + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" +) + +// ============================ +// Request / Response structures +// ============================ + +type ContentItem struct { + Type string `json:"type"` // "text" or "image_url" + Text string `json:"text,omitempty"` // for text type + ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type +} + +type ImageURL struct { + URL string `json:"url"` +} + +type requestPayload struct { + Model string `json:"model"` + Content []ContentItem `json:"content"` +} + +type responsePayload struct { + ID string `json:"id"` // task_id +} + +type responseTask struct { + ID string `json:"id"` + Model string `json:"model"` + Status string `json:"status"` + Content struct { + VideoURL string `json:"video_url"` + } `json:"content"` + Seed int `json:"seed"` + Resolution string `json:"resolution"` + Duration int `json:"duration"` + Ratio string `json:"ratio"` + FramesPerSecond int `json:"framespersecond"` + Usage struct { + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + a.apiKey = info.ApiKey +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + // Accept only POST /v1/video/generations as "generate" action. + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil +} + +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+a.apiKey) + return nil +} + +// BuildRequestBody converts request into Doubao specific format. +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + v, exists := c.Get("task_request") + if !exists { + return nil, fmt.Errorf("request not found in context") + } + req := v.(relaycommon.TaskSubmitReq) + + body, err := a.convertToRequestPayload(&req) + if err != nil { + return nil, errors.Wrap(err, "convert request payload failed") + } + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +// DoRequest delegates to common helper. +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +// DoResponse handles upstream response, returns taskID etc. +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + _ = resp.Body.Close() + + // Parse Doubao response + var dResp responsePayload + if err := json.Unmarshal(responseBody, &dResp); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + + if dResp.ID == "" { + taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, gin.H{"task_id": dResp.ID}) + return dResp.ID, responseBody, nil +} + +// FetchTask fetch task status +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + + uri := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", baseUrl, taskID) + + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return ModelList +} + +func (a *TaskAdaptor) GetChannelName() string { + return ChannelName +} + +func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { + r := requestPayload{ + Model: req.Model, + Content: []ContentItem{}, + } + + // Add text prompt + if req.Prompt != "" { + r.Content = append(r.Content, ContentItem{ + Type: "text", + Text: req.Prompt, + }) + } + + // Add images if present + if req.HasImage() { + for _, imgURL := range req.Images { + r.Content = append(r.Content, ContentItem{ + Type: "image_url", + ImageURL: &ImageURL{ + URL: imgURL, + }, + }) + } + } + + // TODO: Add support for additional parameters from metadata + // such as ratio, duration, seed, etc. + // metadata := req.Metadata + // if metadata != nil { + // // Parse and apply metadata parameters + // } + + return &r, nil +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + resTask := responseTask{} + if err := json.Unmarshal(respBody, &resTask); err != nil { + return nil, errors.Wrap(err, "unmarshal task result failed") + } + + taskResult := relaycommon.TaskInfo{ + Code: 0, + } + + // Map Doubao status to internal status + switch resTask.Status { + case "pending", "queued": + taskResult.Status = model.TaskStatusQueued + taskResult.Progress = "10%" + case "processing": + taskResult.Status = model.TaskStatusInProgress + taskResult.Progress = "50%" + case "succeeded": + taskResult.Status = model.TaskStatusSuccess + taskResult.Progress = "100%" + taskResult.Url = resTask.Content.VideoURL + // 解析 usage 信息用于按倍率计费 + taskResult.CompletionTokens = resTask.Usage.CompletionTokens + taskResult.TotalTokens = resTask.Usage.TotalTokens + case "failed": + taskResult.Status = model.TaskStatusFailure + taskResult.Progress = "100%" + taskResult.Reason = "task failed" + default: + // Unknown status, treat as processing + taskResult.Status = model.TaskStatusInProgress + taskResult.Progress = "30%" + } + + return &taskResult, nil +} diff --git a/relay/channel/task/doubao/constants.go b/relay/channel/task/doubao/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..74b416c6d770f4b708fb03d00429aa0a89112576 --- /dev/null +++ b/relay/channel/task/doubao/constants.go @@ -0,0 +1,9 @@ +package doubao + +var ModelList = []string{ + "doubao-seedance-1-0-pro-250528", + "doubao-seedance-1-0-lite-t2v", + "doubao-seedance-1-0-lite-i2v", +} + +var ChannelName = "doubao-video" diff --git a/relay/channel/task/gemini/adaptor.go b/relay/channel/task/gemini/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..1f8a7b708a13be8c9809a6308877b063aa08545e --- /dev/null +++ b/relay/channel/task/gemini/adaptor.go @@ -0,0 +1,358 @@ +package gemini + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-gonic/gin" + "github.com/pkg/errors" +) + +// VideoGenerationConfig represents the video generation configuration +// Based on: https://ai.google.dev/gemini-api/docs/video +type VideoGenerationConfig struct { + AspectRatio string `json:"aspectRatio,omitempty"` // "16:9" or "9:16" + DurationSeconds float64 `json:"durationSeconds,omitempty"` // 4, 6, or 8 (as number) + NegativePrompt string `json:"negativePrompt,omitempty"` // unwanted elements + PersonGeneration string `json:"personGeneration,omitempty"` // "allow_all" for text-to-video, "allow_adult" for image-to-video + Resolution string `json:"resolution,omitempty"` // video resolution +} + +type Image struct { + BytesBase64Encoded string `json:"bytesBase64Encoded,omitempty"` + MimeType string `json:"mimeType,omitempty"` +} + +type VideoRequest struct { + Prompt string `json:"prompt"` + Image *Image `json:"image,omitempty"` + LastFrame *Image `json:"lastFrame,omitempty"` +} + +// VideoPayload represents the complete video generation request payload +type VideoPayload struct { + Instances []VideoRequest `json:"instances"` + Parameters VideoGenerationConfig `json:"parameters,omitempty"` +} + +type submitResponse struct { + Name string `json:"name"` +} + +type operationVideo struct { + MimeType string `json:"mimeType"` + BytesBase64Encoded string `json:"bytesBase64Encoded"` + Encoding string `json:"encoding"` +} + +type operationResponse struct { + Name string `json:"name"` + Done bool `json:"done"` + Response struct { + Type string `json:"@type"` + RaiMediaFilteredCount int `json:"raiMediaFilteredCount"` + Videos []operationVideo `json:"videos"` + BytesBase64Encoded string `json:"bytesBase64Encoded"` + Encoding string `json:"encoding"` + Video string `json:"video"` + GenerateVideoResponse struct { + GeneratedSamples []struct { + Video struct { + URI string `json:"uri"` + } `json:"video"` + } `json:"generatedSamples"` + RaiMediaFilteredCount int `json:"raiMediaFilteredCount"` + RaiMediaFilteredReasons []string `json:"raiMediaFilteredReasons"` + } `json:"generateVideoResponse"` + } `json:"response"` + Error struct { + Message string `json:"message"` + } `json:"error"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + a.apiKey = info.ApiKey +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + modelName := info.OriginModelName + version := model_setting.GetGeminiVersionSetting(modelName) + + return fmt.Sprintf( + "%s/%s/models/%s:predictLongRunning", + a.baseURL, + version, + modelName, + ), nil +} + +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("x-goog-api-key", a.apiKey) + return nil +} + +// BuildRequestBody converts request into Gemini specific format. +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + v, ok := c.Get("task_request") + if !ok { + return nil, fmt.Errorf("request not found in context") + } + req, ok := v.(relaycommon.TaskSubmitReq) + if !ok { + return nil, fmt.Errorf("unexpected task_request type") + } + + // Create structured video generation request + body := VideoPayload{ + Instances: []VideoRequest{ + {Prompt: req.Prompt}, + }, + Parameters: VideoGenerationConfig{}, + } + + if len(req.Images) > 0 { + body.Instances[0].Image = a.convertImage(req.Images[0]) + } + if len(req.Images) > 1 { + body.Instances[0].LastFrame = a.convertImage(req.Images[1]) + } + + // Parse metadata for additional configuration + metadata := req.Metadata + medaBytes, err := json.Marshal(metadata) + if err != nil { + return nil, errors.Wrap(err, "metadata marshal metadata failed") + } + err = json.Unmarshal(medaBytes, &body.Parameters) + if err != nil { + return nil, errors.Wrap(err, "unmarshal metadata failed") + } + + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +// DoRequest delegates to common helper. +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +// DoResponse handles upstream response, returns taskID etc. +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + } + _ = resp.Body.Close() + + var s submitResponse + if err := json.Unmarshal(responseBody, &s); err != nil { + return "", nil, service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError) + } + if strings.TrimSpace(s.Name) == "" { + return "", nil, service.TaskErrorWrapper(fmt.Errorf("missing operation name"), "invalid_response", http.StatusInternalServerError) + } + taskID = encodeLocalTaskID(s.Name) + ov := dto.NewOpenAIVideo() + ov.ID = taskID + ov.TaskID = taskID + ov.CreatedAt = time.Now().Unix() + ov.Model = info.OriginModelName + c.JSON(http.StatusOK, ov) + return taskID, responseBody, nil +} + +func (a *TaskAdaptor) GetModelList() []string { + return []string{"veo-3.0-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"} +} + +func (a *TaskAdaptor) GetChannelName() string { + return "gemini" +} + +// FetchTask fetch task status +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + + upstreamName, err := decodeLocalTaskID(taskID) + if err != nil { + return nil, fmt.Errorf("decode task_id failed: %w", err) + } + + // For Gemini API, we use GET request to the operations endpoint + version := model_setting.GetGeminiVersionSetting("default") + url := fmt.Sprintf("%s/%s/%s", baseUrl, version, upstreamName) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("x-goog-api-key", key) + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + var op operationResponse + if err := json.Unmarshal(respBody, &op); err != nil { + return nil, fmt.Errorf("unmarshal operation response failed: %w", err) + } + + ti := &relaycommon.TaskInfo{} + + if op.Error.Message != "" { + ti.Status = model.TaskStatusFailure + ti.Reason = op.Error.Message + ti.Progress = "100%" + return ti, nil + } + + if !op.Done { + ti.Status = model.TaskStatusInProgress + ti.Progress = "50%" + return ti, nil + } + + if len(op.Response.GenerateVideoResponse.GeneratedSamples) == 0 { + ti.Status = model.TaskStatusFailure + ti.Reason = fmt.Sprintf("no generated video url found: %s", strings.Join(op.Response.GenerateVideoResponse.RaiMediaFilteredReasons, "; ")) + } else { + if uri := op.Response.GenerateVideoResponse.GeneratedSamples[0].Video.URI; uri != "" { + ti.RemoteUrl = uri + } + ti.Status = model.TaskStatusSuccess + } + ti.Progress = "100%" + taskID := encodeLocalTaskID(op.Name) + ti.TaskID = taskID + ti.Url = fmt.Sprintf("%s/v1/videos/%s/content", system_setting.ServerAddress, taskID) + return ti, nil +} + +func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) { + upstreamName, err := decodeLocalTaskID(task.TaskID) + if err != nil { + upstreamName = "" + } + modelName := extractModelFromOperationName(upstreamName) + if strings.TrimSpace(modelName) == "" { + modelName = "veo-3.0-generate-001" + } + + video := dto.NewOpenAIVideo() + video.ID = task.TaskID + video.Model = modelName + video.Status = task.Status.ToVideoStatus() + video.SetProgressStr(task.Progress) + video.CreatedAt = task.CreatedAt + if task.FinishTime > 0 { + video.CompletedAt = task.FinishTime + } else if task.UpdatedAt > 0 { + video.CompletedAt = task.UpdatedAt + } + + return common.Marshal(video) +} + +func (a *TaskAdaptor) convertImage(imageStr string) *Image { + if strings.TrimSpace(imageStr) == "" { + return nil + } + img := &Image{ + MimeType: "image/png", + BytesBase64Encoded: imageStr, + } + if strings.HasPrefix(imageStr, "data:image/") { + parts := strings.Split(imageStr, ";base64,") + if len(parts) == 2 { + img.MimeType = strings.TrimPrefix(parts[0], "data:") + img.BytesBase64Encoded = parts[1] + } + } else if strings.HasPrefix(imageStr, "http") { + mimeType, data, err := service.GetImageFromUrl(imageStr) + if err == nil { + img.MimeType = mimeType + img.BytesBase64Encoded = data + } + } + return img +} + +// ============================ +// helpers +// ============================ + +func encodeLocalTaskID(name string) string { + return base64.RawURLEncoding.EncodeToString([]byte(name)) +} + +func decodeLocalTaskID(local string) (string, error) { + b, err := base64.RawURLEncoding.DecodeString(local) + if err != nil { + return "", err + } + return string(b), nil +} + +var modelRe = regexp.MustCompile(`models/([^/]+)/operations/`) + +func extractModelFromOperationName(name string) string { + if name == "" { + return "" + } + if m := modelRe.FindStringSubmatch(name); len(m) == 2 { + return m[1] + } + if idx := strings.Index(name, "models/"); idx >= 0 { + s := name[idx+len("models/"):] + if p := strings.Index(s, "/operations/"); p > 0 { + return s[:p] + } + } + return "" +} diff --git a/relay/channel/task/hailuo/adaptor.go b/relay/channel/task/hailuo/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..cb6f1eebda8045121a614c5b641e0feeea68be9b --- /dev/null +++ b/relay/channel/task/hailuo/adaptor.go @@ -0,0 +1,297 @@ +package hailuo + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/pkg/errors" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" +) + +// https://platform.minimaxi.com/docs/api-reference/video-generation-intro +type TaskAdaptor struct { + ChannelType int + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + a.apiKey = info.ApiKey +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) +} + +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s%s", a.baseURL, TextToVideoEndpoint), nil +} + +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+a.apiKey) + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + v, exists := c.Get("task_request") + if !exists { + return nil, fmt.Errorf("request not found in context") + } + req, ok := v.(relaycommon.TaskSubmitReq) + if !ok { + return nil, fmt.Errorf("invalid request type in context") + } + + body, err := a.convertToRequestPayload(&req) + if err != nil { + return nil, errors.Wrap(err, "convert request payload failed") + } + + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + + return bytes.NewReader(data), nil +} + +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + _ = resp.Body.Close() + + var hResp VideoResponse + if err := json.Unmarshal(responseBody, &hResp); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + + if hResp.BaseResp.StatusCode != StatusSuccess { + taskErr = service.TaskErrorWrapper( + fmt.Errorf("hailuo api error: %s", hResp.BaseResp.StatusMsg), + strconv.Itoa(hResp.BaseResp.StatusCode), + http.StatusBadRequest, + ) + return + } + + ov := dto.NewOpenAIVideo() + ov.ID = hResp.TaskID + ov.TaskID = hResp.TaskID + ov.CreatedAt = time.Now().Unix() + ov.Model = info.OriginModelName + + c.JSON(http.StatusOK, ov) + return hResp.TaskID, responseBody, nil +} + +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + + uri := fmt.Sprintf("%s%s?task_id=%s", baseUrl, QueryTaskEndpoint, taskID) + + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return ModelList +} + +func (a *TaskAdaptor) GetChannelName() string { + return ChannelName +} + +func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*VideoRequest, error) { + modelConfig := GetModelConfig(req.Model) + duration := DefaultDuration + if req.Duration > 0 { + duration = req.Duration + } + resolution := modelConfig.DefaultResolution + if req.Size != "" { + resolution = a.parseResolutionFromSize(req.Size, modelConfig) + } + + videoRequest := &VideoRequest{ + Model: req.Model, + Prompt: req.Prompt, + Duration: &duration, + Resolution: resolution, + } + if err := req.UnmarshalMetadata(&videoRequest); err != nil { + return nil, errors.Wrap(err, "unmarshal metadata to video request failed") + } + + return videoRequest, nil +} + +func (a *TaskAdaptor) parseResolutionFromSize(size string, modelConfig ModelConfig) string { + switch { + case strings.Contains(size, "1080"): + return Resolution1080P + case strings.Contains(size, "768"): + return Resolution768P + case strings.Contains(size, "720"): + return Resolution720P + case strings.Contains(size, "512"): + return Resolution512P + default: + return modelConfig.DefaultResolution + } +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + resTask := QueryTaskResponse{} + if err := json.Unmarshal(respBody, &resTask); err != nil { + return nil, errors.Wrap(err, "unmarshal task result failed") + } + + taskResult := relaycommon.TaskInfo{} + + if resTask.BaseResp.StatusCode == StatusSuccess { + taskResult.Code = 0 + } else { + taskResult.Code = resTask.BaseResp.StatusCode + taskResult.Reason = resTask.BaseResp.StatusMsg + taskResult.Status = model.TaskStatusFailure + taskResult.Progress = "100%" + } + + switch resTask.Status { + case TaskStatusPreparing, TaskStatusQueueing, TaskStatusProcessing: + taskResult.Status = model.TaskStatusInProgress + taskResult.Progress = "30%" + if resTask.Status == TaskStatusProcessing { + taskResult.Progress = "50%" + } + case TaskStatusSuccess: + taskResult.Status = model.TaskStatusSuccess + taskResult.Progress = "100%" + taskResult.Url = a.buildVideoURL(resTask.TaskID, resTask.FileID) + case TaskStatusFailed: + taskResult.Status = model.TaskStatusFailure + taskResult.Progress = "100%" + if taskResult.Reason == "" { + taskResult.Reason = "task failed" + } + default: + taskResult.Status = model.TaskStatusInProgress + taskResult.Progress = "30%" + } + + return &taskResult, nil +} + +func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) { + var hailuoResp QueryTaskResponse + if err := json.Unmarshal(originTask.Data, &hailuoResp); err != nil { + return nil, errors.Wrap(err, "unmarshal hailuo task data failed") + } + + openAIVideo := originTask.ToOpenAIVideo() + if hailuoResp.BaseResp.StatusCode != StatusSuccess { + openAIVideo.Error = &dto.OpenAIVideoError{ + Message: hailuoResp.BaseResp.StatusMsg, + Code: strconv.Itoa(hailuoResp.BaseResp.StatusCode), + } + } + + jsonData, err := common.Marshal(openAIVideo) + if err != nil { + return nil, errors.Wrap(err, "marshal openai video failed") + } + + return jsonData, nil +} + +func (a *TaskAdaptor) buildVideoURL(_, fileID string) string { + if a.apiKey == "" || a.baseURL == "" { + return "" + } + + url := fmt.Sprintf("%s/v1/files/retrieve?file_id=%s", a.baseURL, fileID) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "" + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+a.apiKey) + + resp, err := service.GetHttpClient().Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + + var retrieveResp RetrieveFileResponse + if err := json.Unmarshal(responseBody, &retrieveResp); err != nil { + return "" + } + + if retrieveResp.BaseResp.StatusCode != StatusSuccess { + return "" + } + + return retrieveResp.File.DownloadURL +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func containsInt(slice []int, item int) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/relay/channel/task/hailuo/constants.go b/relay/channel/task/hailuo/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..5e54086374f968a032f7afe28cd4237b04afc106 --- /dev/null +++ b/relay/channel/task/hailuo/constants.go @@ -0,0 +1,52 @@ +package hailuo + +const ( + ChannelName = "hailuo-video" +) + +var ModelList = []string{ + "MiniMax-Hailuo-2.3", + "MiniMax-Hailuo-2.3-Fast", + "MiniMax-Hailuo-02", + "T2V-01-Director", + "T2V-01", + "I2V-01-Director", + "I2V-01-live", + "I2V-01", + "S2V-01", +} + +const ( + TextToVideoEndpoint = "/v1/video_generation" + QueryTaskEndpoint = "/v1/query/video_generation" +) + +const ( + StatusSuccess = 0 + StatusRateLimit = 1002 + StatusAuthFailed = 1004 + StatusNoBalance = 1008 + StatusSensitive = 1026 + StatusParamError = 2013 + StatusInvalidKey = 2049 +) + +const ( + TaskStatusPreparing = "Preparing" + TaskStatusQueueing = "Queueing" + TaskStatusProcessing = "Processing" + TaskStatusSuccess = "Success" + TaskStatusFailed = "Fail" +) + +const ( + Resolution512P = "512P" + Resolution720P = "720P" + Resolution768P = "768P" + Resolution1080P = "1080P" +) + +const ( + DefaultDuration = 6 + DefaultResolution = Resolution720P +) diff --git a/relay/channel/task/hailuo/models.go b/relay/channel/task/hailuo/models.go new file mode 100644 index 0000000000000000000000000000000000000000..09a97766f15d6d0bcafc244c67d500c0e546508c --- /dev/null +++ b/relay/channel/task/hailuo/models.go @@ -0,0 +1,170 @@ +package hailuo + +type SubjectReference struct { + Type string `json:"type"` // Subject type, currently only supports "character" + Image []string `json:"image"` // Array of subject reference images (currently only supports single image) +} + +type VideoRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt,omitempty"` + PromptOptimizer *bool `json:"prompt_optimizer,omitempty"` + FastPretreatment *bool `json:"fast_pretreatment,omitempty"` + Duration *int `json:"duration,omitempty"` + Resolution string `json:"resolution,omitempty"` + CallbackURL string `json:"callback_url,omitempty"` + AigcWatermark *bool `json:"aigc_watermark,omitempty"` + FirstFrameImage string `json:"first_frame_image,omitempty"` // For image-to-video and start-end-to-video + LastFrameImage string `json:"last_frame_image,omitempty"` // For start-end-to-video + SubjectReference []SubjectReference `json:"subject_reference,omitempty"` // For subject-reference-to-video +} + +type VideoResponse struct { + TaskID string `json:"task_id"` + BaseResp BaseResp `json:"base_resp"` +} + +type BaseResp struct { + StatusCode int `json:"status_code"` + StatusMsg string `json:"status_msg"` +} + +type QueryTaskRequest struct { + TaskID string `json:"task_id"` +} + +type QueryTaskResponse struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + FileID string `json:"file_id,omitempty"` + VideoWidth int `json:"video_width,omitempty"` + VideoHeight int `json:"video_height,omitempty"` + BaseResp BaseResp `json:"base_resp"` +} + +type ErrorInfo struct { + StatusCode int `json:"status_code"` + StatusMsg string `json:"status_msg"` +} + +type TaskStatusInfo struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + FileID string `json:"file_id,omitempty"` + VideoURL string `json:"video_url,omitempty"` + ErrorCode int `json:"error_code,omitempty"` + ErrorMsg string `json:"error_msg,omitempty"` +} + +type ModelConfig struct { + Name string + DefaultResolution string + SupportedDurations []int + SupportedResolutions []string + HasPromptOptimizer bool + HasFastPretreatment bool +} + +type RetrieveFileResponse struct { + File FileObject `json:"file"` + BaseResp BaseResp `json:"base_resp"` +} + +type FileObject struct { + FileID int64 `json:"file_id"` + Bytes int64 `json:"bytes"` + CreatedAt int64 `json:"created_at"` + Filename string `json:"filename"` + Purpose string `json:"purpose"` + DownloadURL string `json:"download_url"` +} + +func GetModelConfig(model string) ModelConfig { + configs := map[string]ModelConfig{ + "MiniMax-Hailuo-2.3": { + Name: "MiniMax-Hailuo-2.3", + DefaultResolution: Resolution768P, + SupportedDurations: []int{6, 10}, + SupportedResolutions: []string{Resolution768P, Resolution1080P}, + HasPromptOptimizer: true, + HasFastPretreatment: true, + }, + "MiniMax-Hailuo-2.3-Fast": { + Name: "MiniMax-Hailuo-2.3-Fast", + DefaultResolution: Resolution768P, + SupportedDurations: []int{6, 10}, + SupportedResolutions: []string{Resolution768P, Resolution1080P}, + HasPromptOptimizer: true, + HasFastPretreatment: true, + }, + "MiniMax-Hailuo-02": { + Name: "MiniMax-Hailuo-02", + DefaultResolution: Resolution768P, + SupportedDurations: []int{6, 10}, + SupportedResolutions: []string{Resolution512P, Resolution768P, Resolution1080P}, + HasPromptOptimizer: true, + HasFastPretreatment: true, + }, + "T2V-01-Director": { + Name: "T2V-01-Director", + DefaultResolution: Resolution768P, + SupportedDurations: []int{6}, + SupportedResolutions: []string{Resolution768P, Resolution1080P}, + HasPromptOptimizer: true, + HasFastPretreatment: false, + }, + "T2V-01": { + Name: "T2V-01", + DefaultResolution: Resolution720P, + SupportedDurations: []int{6}, + SupportedResolutions: []string{Resolution720P}, + HasPromptOptimizer: true, + HasFastPretreatment: false, + }, + "I2V-01-Director": { + Name: "I2V-01-Director", + DefaultResolution: Resolution720P, + SupportedDurations: []int{6}, + SupportedResolutions: []string{Resolution720P, Resolution1080P}, + HasPromptOptimizer: true, + HasFastPretreatment: false, + }, + "I2V-01-live": { + Name: "I2V-01-live", + DefaultResolution: Resolution720P, + SupportedDurations: []int{6}, + SupportedResolutions: []string{Resolution720P, Resolution1080P}, + HasPromptOptimizer: true, + HasFastPretreatment: false, + }, + "I2V-01": { + Name: "I2V-01", + DefaultResolution: Resolution720P, + SupportedDurations: []int{6}, + SupportedResolutions: []string{Resolution720P, Resolution1080P}, + HasPromptOptimizer: true, + HasFastPretreatment: false, + }, + "S2V-01": { + Name: "S2V-01", + DefaultResolution: Resolution720P, + SupportedDurations: []int{6}, + SupportedResolutions: []string{Resolution720P}, + HasPromptOptimizer: true, + HasFastPretreatment: false, + }, + } + + if config, exists := configs[model]; exists { + return config + } + + return ModelConfig{ + Name: model, + DefaultResolution: DefaultResolution, + SupportedDurations: []int{6}, + SupportedResolutions: []string{DefaultResolution}, + HasPromptOptimizer: true, + HasFastPretreatment: false, + } +} diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..da4a1f8fe02e787e81cb3f535e756f0dc88502a1 --- /dev/null +++ b/relay/channel/task/jimeng/adaptor.go @@ -0,0 +1,480 @@ +package jimeng + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" +) + +// ============================ +// Request / Response structures +// ============================ + +type requestPayload struct { + ReqKey string `json:"req_key"` + BinaryDataBase64 []string `json:"binary_data_base64,omitempty"` + ImageUrls []string `json:"image_urls,omitempty"` + Prompt string `json:"prompt,omitempty"` + Seed int64 `json:"seed"` + AspectRatio string `json:"aspect_ratio"` + Frames int `json:"frames,omitempty"` +} + +type responsePayload struct { + Code int `json:"code"` + Message string `json:"message"` + RequestId string `json:"request_id"` + Data struct { + TaskID string `json:"task_id"` + } `json:"data"` +} + +type responseTask struct { + Code int `json:"code"` + Data struct { + BinaryDataBase64 []interface{} `json:"binary_data_base64"` + ImageUrls interface{} `json:"image_urls"` + RespData string `json:"resp_data"` + Status string `json:"status"` + VideoUrl string `json:"video_url"` + } `json:"data"` + Message string `json:"message"` + RequestId string `json:"request_id"` + Status int `json:"status"` + TimeElapsed string `json:"time_elapsed"` +} + +const ( + // 即梦限制单个文件最大4.7MB https://www.volcengine.com/docs/85621/1747301 + MaxFileSize int64 = 4*1024*1024 + 700*1024 // 4.7MB (4MB + 724KB) +) + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + accessKey string + secretKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + + // apiKey format: "access_key|secret_key" + keyParts := strings.Split(info.ApiKey, "|") + if len(keyParts) == 2 { + a.accessKey = strings.TrimSpace(keyParts[0]) + a.secretKey = strings.TrimSpace(keyParts[1]) + } +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + if isNewAPIRelay(info.ApiKey) { + return fmt.Sprintf("%s/jimeng/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil + } + return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil +} + +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if isNewAPIRelay(info.ApiKey) { + req.Header.Set("Authorization", "Bearer "+info.ApiKey) + } else { + return a.signRequest(req, a.accessKey, a.secretKey) + } + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + v, exists := c.Get("task_request") + if !exists { + return nil, fmt.Errorf("request not found in context") + } + req, ok := v.(relaycommon.TaskSubmitReq) + if !ok { + return nil, fmt.Errorf("invalid request type in context") + } + // 支持openai sdk的图片上传方式 + if mf, err := c.MultipartForm(); err == nil { + if files, exists := mf.File["input_reference"]; exists && len(files) > 0 { + if len(files) == 1 { + info.Action = constant.TaskActionGenerate + } else if len(files) > 1 { + info.Action = constant.TaskActionFirstTailGenerate + } + + // 将上传的文件转换为base64格式 + var images []string + + for _, fileHeader := range files { + // 检查文件大小 + if fileHeader.Size > MaxFileSize { + return nil, fmt.Errorf("文件 %s 大小超过限制,最大允许 %d MB", fileHeader.Filename, MaxFileSize/(1024*1024)) + } + + file, err := fileHeader.Open() + if err != nil { + continue + } + fileBytes, err := io.ReadAll(file) + file.Close() + if err != nil { + continue + } + // 将文件内容转换为base64 + base64Str := base64.StdEncoding.EncodeToString(fileBytes) + images = append(images, base64Str) + } + req.Images = images + } + } + + body, err := a.convertToRequestPayload(&req) + if err != nil { + return nil, errors.Wrap(err, "convert request payload failed") + } + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +// DoRequest delegates to common helper. +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +// DoResponse handles upstream response, returns taskID etc. +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + _ = resp.Body.Close() + + // Parse Jimeng response + var jResp responsePayload + if err := json.Unmarshal(responseBody, &jResp); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + + if jResp.Code != 10000 { + taskErr = service.TaskErrorWrapper(fmt.Errorf(jResp.Message), fmt.Sprintf("%d", jResp.Code), http.StatusInternalServerError) + return + } + + ov := dto.NewOpenAIVideo() + ov.ID = jResp.Data.TaskID + ov.TaskID = jResp.Data.TaskID + ov.CreatedAt = time.Now().Unix() + ov.Model = info.OriginModelName + c.JSON(http.StatusOK, ov) + return jResp.Data.TaskID, responseBody, nil +} + +// FetchTask fetch task status +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + + uri := fmt.Sprintf("%s/?Action=CVSync2AsyncGetResult&Version=2022-08-31", baseUrl) + if isNewAPIRelay(key) { + uri = fmt.Sprintf("%s/jimeng/?Action=CVSync2AsyncGetResult&Version=2022-08-31", a.baseURL) + } + payload := map[string]string{ + "req_key": "jimeng_vgfm_t2v_l20", // This is fixed value from doc: https://www.volcengine.com/docs/85621/1544774 + "task_id": taskID, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, errors.Wrap(err, "marshal fetch task payload failed") + } + + req, err := http.NewRequest(http.MethodPost, uri, bytes.NewBuffer(payloadBytes)) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + if isNewAPIRelay(key) { + req.Header.Set("Authorization", "Bearer "+key) + } else { + keyParts := strings.Split(key, "|") + if len(keyParts) != 2 { + return nil, fmt.Errorf("invalid api key format for jimeng: expected 'ak|sk'") + } + accessKey := strings.TrimSpace(keyParts[0]) + secretKey := strings.TrimSpace(keyParts[1]) + + if err := a.signRequest(req, accessKey, secretKey); err != nil { + return nil, errors.Wrap(err, "sign request failed") + } + } + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return []string{"jimeng_vgfm_t2v_l20"} +} + +func (a *TaskAdaptor) GetChannelName() string { + return "jimeng" +} + +func (a *TaskAdaptor) signRequest(req *http.Request, accessKey, secretKey string) error { + var bodyBytes []byte + var err error + + if req.Body != nil { + bodyBytes, err = io.ReadAll(req.Body) + if err != nil { + return errors.Wrap(err, "read request body failed") + } + _ = req.Body.Close() + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Rewind + } else { + bodyBytes = []byte{} + } + + payloadHash := sha256.Sum256(bodyBytes) + hexPayloadHash := hex.EncodeToString(payloadHash[:]) + + t := time.Now().UTC() + xDate := t.Format("20060102T150405Z") + shortDate := t.Format("20060102") + + req.Header.Set("Host", req.URL.Host) + req.Header.Set("X-Date", xDate) + req.Header.Set("X-Content-Sha256", hexPayloadHash) + + // Sort and encode query parameters to create canonical query string + queryParams := req.URL.Query() + sortedKeys := make([]string, 0, len(queryParams)) + for k := range queryParams { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + var queryParts []string + for _, k := range sortedKeys { + values := queryParams[k] + sort.Strings(values) + for _, v := range values { + queryParts = append(queryParts, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(v))) + } + } + canonicalQueryString := strings.Join(queryParts, "&") + + headersToSign := map[string]string{ + "host": req.URL.Host, + "x-date": xDate, + "x-content-sha256": hexPayloadHash, + } + if req.Header.Get("Content-Type") != "" { + headersToSign["content-type"] = req.Header.Get("Content-Type") + } + + var signedHeaderKeys []string + for k := range headersToSign { + signedHeaderKeys = append(signedHeaderKeys, k) + } + sort.Strings(signedHeaderKeys) + + var canonicalHeaders strings.Builder + for _, k := range signedHeaderKeys { + canonicalHeaders.WriteString(k) + canonicalHeaders.WriteString(":") + canonicalHeaders.WriteString(strings.TrimSpace(headersToSign[k])) + canonicalHeaders.WriteString("\n") + } + signedHeaders := strings.Join(signedHeaderKeys, ";") + + canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", + req.Method, + req.URL.Path, + canonicalQueryString, + canonicalHeaders.String(), + signedHeaders, + hexPayloadHash, + ) + + hashedCanonicalRequest := sha256.Sum256([]byte(canonicalRequest)) + hexHashedCanonicalRequest := hex.EncodeToString(hashedCanonicalRequest[:]) + + region := "cn-north-1" + serviceName := "cv" + credentialScope := fmt.Sprintf("%s/%s/%s/request", shortDate, region, serviceName) + stringToSign := fmt.Sprintf("HMAC-SHA256\n%s\n%s\n%s", + xDate, + credentialScope, + hexHashedCanonicalRequest, + ) + + kDate := hmacSHA256([]byte(secretKey), []byte(shortDate)) + kRegion := hmacSHA256(kDate, []byte(region)) + kService := hmacSHA256(kRegion, []byte(serviceName)) + kSigning := hmacSHA256(kService, []byte("request")) + signature := hex.EncodeToString(hmacSHA256(kSigning, []byte(stringToSign))) + + authorization := fmt.Sprintf("HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", + accessKey, + credentialScope, + signedHeaders, + signature, + ) + req.Header.Set("Authorization", authorization) + return nil +} + +func hmacSHA256(key []byte, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} + +func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { + r := requestPayload{ + ReqKey: req.Model, + Prompt: req.Prompt, + } + + switch req.Duration { + case 10: + r.Frames = 241 // 24*10+1 = 241 + default: + r.Frames = 121 // 24*5+1 = 121 + } + + // Handle one-of image_urls or binary_data_base64 + if req.HasImage() { + if strings.HasPrefix(req.Images[0], "http") { + r.ImageUrls = req.Images + } else { + r.BinaryDataBase64 = req.Images + } + } + metadata := req.Metadata + medaBytes, err := json.Marshal(metadata) + if err != nil { + return nil, errors.Wrap(err, "metadata marshal metadata failed") + } + err = json.Unmarshal(medaBytes, &r) + if err != nil { + return nil, errors.Wrap(err, "unmarshal metadata failed") + } + + // 即梦视频3.0 ReqKey转换 + // https://www.volcengine.com/docs/85621/1792707 + if strings.Contains(r.ReqKey, "jimeng_v30") { + if r.ReqKey == "jimeng_v30_pro" { + // 3.0 pro只有固定的jimeng_ti2v_v30_pro + r.ReqKey = "jimeng_ti2v_v30_pro" + } else if len(req.Images) > 1 { + // 多张图片:首尾帧生成 + r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1), "p") + } else if len(req.Images) == 1 { + // 单张图片:图生视频 + r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1), "p") + } else { + // 无图片:文生视频 + r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_t2v_v30", 1) + } + } + + return &r, nil +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + resTask := responseTask{} + if err := json.Unmarshal(respBody, &resTask); err != nil { + return nil, errors.Wrap(err, "unmarshal task result failed") + } + taskResult := relaycommon.TaskInfo{} + if resTask.Code == 10000 { + taskResult.Code = 0 + } else { + taskResult.Code = resTask.Code // todo uni code + taskResult.Reason = resTask.Message + taskResult.Status = model.TaskStatusFailure + taskResult.Progress = "100%" + } + switch resTask.Data.Status { + case "in_queue": + taskResult.Status = model.TaskStatusQueued + taskResult.Progress = "10%" + case "done": + taskResult.Status = model.TaskStatusSuccess + taskResult.Progress = "100%" + } + taskResult.Url = resTask.Data.VideoUrl + return &taskResult, nil +} + +func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) { + var jimengResp responseTask + if err := json.Unmarshal(originTask.Data, &jimengResp); err != nil { + return nil, errors.Wrap(err, "unmarshal jimeng task data failed") + } + + openAIVideo := dto.NewOpenAIVideo() + openAIVideo.ID = originTask.TaskID + openAIVideo.Status = originTask.Status.ToVideoStatus() + openAIVideo.SetProgressStr(originTask.Progress) + openAIVideo.SetMetadata("url", jimengResp.Data.VideoUrl) + openAIVideo.CreatedAt = originTask.CreatedAt + openAIVideo.CompletedAt = originTask.UpdatedAt + + if jimengResp.Code != 10000 { + openAIVideo.Error = &dto.OpenAIVideoError{ + Message: jimengResp.Message, + Code: fmt.Sprintf("%d", jimengResp.Code), + } + } + + jsonData, _ := common.Marshal(openAIVideo) + return jsonData, nil +} + +func isNewAPIRelay(apiKey string) bool { + return strings.HasPrefix(apiKey, "sk-") +} diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..c1bbd9d5909ad02635c35d939f1c0813db898e0c --- /dev/null +++ b/relay/channel/task/kling/adaptor.go @@ -0,0 +1,402 @@ +package kling + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/samber/lo" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/pkg/errors" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" +) + +// ============================ +// Request / Response structures +// ============================ + +type TrajectoryPoint struct { + X int `json:"x"` + Y int `json:"y"` +} + +type DynamicMask struct { + Mask string `json:"mask,omitempty"` + Trajectories []TrajectoryPoint `json:"trajectories,omitempty"` +} + +type CameraConfig struct { + Horizontal float64 `json:"horizontal,omitempty"` + Vertical float64 `json:"vertical,omitempty"` + Pan float64 `json:"pan,omitempty"` + Tilt float64 `json:"tilt,omitempty"` + Roll float64 `json:"roll,omitempty"` + Zoom float64 `json:"zoom,omitempty"` +} + +type CameraControl struct { + Type string `json:"type,omitempty"` + Config *CameraConfig `json:"config,omitempty"` +} + +type requestPayload struct { + Prompt string `json:"prompt,omitempty"` + Image string `json:"image,omitempty"` + ImageTail string `json:"image_tail,omitempty"` + NegativePrompt string `json:"negative_prompt,omitempty"` + Mode string `json:"mode,omitempty"` + Duration string `json:"duration,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + ModelName string `json:"model_name,omitempty"` + Model string `json:"model,omitempty"` // Compatible with upstreams that only recognize "model" + CfgScale float64 `json:"cfg_scale,omitempty"` + StaticMask string `json:"static_mask,omitempty"` + DynamicMasks []DynamicMask `json:"dynamic_masks,omitempty"` + CameraControl *CameraControl `json:"camera_control,omitempty"` + CallbackUrl string `json:"callback_url,omitempty"` + ExternalTaskId string `json:"external_task_id,omitempty"` +} + +type responsePayload struct { + Code int `json:"code"` + Message string `json:"message"` + TaskId string `json:"task_id"` + RequestId string `json:"request_id"` + Data struct { + TaskId string `json:"task_id"` + TaskStatus string `json:"task_status"` + TaskStatusMsg string `json:"task_status_msg"` + TaskResult struct { + Videos []struct { + Id string `json:"id"` + Url string `json:"url"` + Duration string `json:"duration"` + } `json:"videos"` + } `json:"task_result"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } `json:"data"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + a.apiKey = info.ApiKey + + // apiKey format: "access_key|secret_key" +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + // Use the standard validation method for TaskSubmitReq + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video") + + if isNewAPIRelay(info.ApiKey) { + return fmt.Sprintf("%s/kling%s", a.baseURL, path), nil + } + + return fmt.Sprintf("%s%s", a.baseURL, path), nil +} + +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + token, err := a.createJWTToken() + if err != nil { + return fmt.Errorf("failed to create JWT token: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", "kling-sdk/1.0") + return nil +} + +// BuildRequestBody converts request into Kling specific format. +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + v, exists := c.Get("task_request") + if !exists { + return nil, fmt.Errorf("request not found in context") + } + req := v.(relaycommon.TaskSubmitReq) + + body, err := a.convertToRequestPayload(&req) + if err != nil { + return nil, err + } + if body.Image == "" && body.ImageTail == "" { + c.Set("action", constant.TaskActionTextGenerate) + } + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +// DoRequest delegates to common helper. +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + if action := c.GetString("action"); action != "" { + info.Action = action + } + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +// DoResponse handles upstream response, returns taskID etc. +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + + var kResp responsePayload + err = json.Unmarshal(responseBody, &kResp) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError) + return + } + if kResp.Code != 0 { + taskErr = service.TaskErrorWrapperLocal(fmt.Errorf(kResp.Message), "task_failed", http.StatusBadRequest) + return + } + ov := dto.NewOpenAIVideo() + ov.ID = kResp.Data.TaskId + ov.TaskID = kResp.Data.TaskId + ov.CreatedAt = time.Now().Unix() + ov.Model = info.OriginModelName + c.JSON(http.StatusOK, ov) + return kResp.Data.TaskId, responseBody, nil +} + +// FetchTask fetch task status +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + action, ok := body["action"].(string) + if !ok { + return nil, fmt.Errorf("invalid action") + } + path := lo.Ternary(action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video") + url := fmt.Sprintf("%s%s/%s", baseUrl, path, taskID) + if isNewAPIRelay(key) { + url = fmt.Sprintf("%s/kling%s/%s", baseUrl, path, taskID) + } + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + token, err := a.createJWTTokenWithKey(key) + if err != nil { + token = key + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", "kling-sdk/1.0") + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return []string{"kling-v1", "kling-v1-6", "kling-v2-master"} +} + +func (a *TaskAdaptor) GetChannelName() string { + return "kling" +} + +// ============================ +// helpers +// ============================ + +func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { + r := requestPayload{ + Prompt: req.Prompt, + Image: req.Image, + Mode: defaultString(req.Mode, "std"), + Duration: fmt.Sprintf("%d", defaultInt(req.Duration, 5)), + AspectRatio: a.getAspectRatio(req.Size), + ModelName: req.Model, + Model: req.Model, // Keep consistent with model_name, double writing improves compatibility + CfgScale: 0.5, + StaticMask: "", + DynamicMasks: []DynamicMask{}, + CameraControl: nil, + CallbackUrl: "", + ExternalTaskId: "", + } + if r.ModelName == "" { + r.ModelName = "kling-v1" + } + metadata := req.Metadata + medaBytes, err := json.Marshal(metadata) + if err != nil { + return nil, errors.Wrap(err, "metadata marshal metadata failed") + } + err = json.Unmarshal(medaBytes, &r) + if err != nil { + return nil, errors.Wrap(err, "unmarshal metadata failed") + } + return &r, nil +} + +func (a *TaskAdaptor) getAspectRatio(size string) string { + switch size { + case "1024x1024", "512x512": + return "1:1" + case "1280x720", "1920x1080": + return "16:9" + case "720x1280", "1080x1920": + return "9:16" + default: + return "1:1" + } +} + +func defaultString(s, def string) string { + if strings.TrimSpace(s) == "" { + return def + } + return s +} + +func defaultInt(v int, def int) int { + if v == 0 { + return def + } + return v +} + +// ============================ +// JWT helpers +// ============================ + +func (a *TaskAdaptor) createJWTToken() (string, error) { + return a.createJWTTokenWithKey(a.apiKey) +} + +func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) { + if isNewAPIRelay(apiKey) { + return apiKey, nil // new api relay + } + keyParts := strings.Split(apiKey, "|") + if len(keyParts) != 2 { + return "", errors.New("invalid api_key, required format is accessKey|secretKey") + } + accessKey := strings.TrimSpace(keyParts[0]) + if len(keyParts) == 1 { + return accessKey, nil + } + secretKey := strings.TrimSpace(keyParts[1]) + now := time.Now().Unix() + claims := jwt.MapClaims{ + "iss": accessKey, + "exp": now + 1800, // 30 minutes + "nbf": now - 5, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + token.Header["typ"] = "JWT" + return token.SignedString([]byte(secretKey)) +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + taskInfo := &relaycommon.TaskInfo{} + resPayload := responsePayload{} + err := json.Unmarshal(respBody, &resPayload) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal response body") + } + taskInfo.Code = resPayload.Code + taskInfo.TaskID = resPayload.Data.TaskId + taskInfo.Reason = resPayload.Message + //任务状态,枚举值:submitted(已提交)、processing(处理中)、succeed(成功)、failed(失败) + status := resPayload.Data.TaskStatus + switch status { + case "submitted": + taskInfo.Status = model.TaskStatusSubmitted + case "processing": + taskInfo.Status = model.TaskStatusInProgress + case "succeed": + taskInfo.Status = model.TaskStatusSuccess + case "failed": + taskInfo.Status = model.TaskStatusFailure + default: + return nil, fmt.Errorf("unknown task status: %s", status) + } + if videos := resPayload.Data.TaskResult.Videos; len(videos) > 0 { + video := videos[0] + taskInfo.Url = video.Url + } + return taskInfo, nil +} + +func isNewAPIRelay(apiKey string) bool { + return strings.HasPrefix(apiKey, "sk-") +} + +func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) { + var klingResp responsePayload + if err := json.Unmarshal(originTask.Data, &klingResp); err != nil { + return nil, errors.Wrap(err, "unmarshal kling task data failed") + } + + openAIVideo := dto.NewOpenAIVideo() + openAIVideo.ID = originTask.TaskID + openAIVideo.Status = originTask.Status.ToVideoStatus() + openAIVideo.SetProgressStr(originTask.Progress) + openAIVideo.CreatedAt = klingResp.Data.CreatedAt + openAIVideo.CompletedAt = klingResp.Data.UpdatedAt + + if len(klingResp.Data.TaskResult.Videos) > 0 { + video := klingResp.Data.TaskResult.Videos[0] + if video.Url != "" { + openAIVideo.SetMetadata("url", video.Url) + } + if video.Duration != "" { + openAIVideo.Seconds = video.Duration + } + } + + if klingResp.Code != 0 && klingResp.Message != "" { + openAIVideo.Error = &dto.OpenAIVideoError{ + Message: klingResp.Message, + Code: fmt.Sprintf("%d", klingResp.Code), + } + } + jsonData, _ := common.Marshal(openAIVideo) + return jsonData, nil +} diff --git a/relay/channel/task/sora/adaptor.go b/relay/channel/task/sora/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..17aec18f08a175e40c00ecba0fb346b009fe686c --- /dev/null +++ b/relay/channel/task/sora/adaptor.go @@ -0,0 +1,190 @@ +package sora + +import ( + "bytes" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" +) + +// ============================ +// Request / Response structures +// ============================ + +type ContentItem struct { + Type string `json:"type"` // "text" or "image_url" + Text string `json:"text,omitempty"` // for text type + ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type +} + +type ImageURL struct { + URL string `json:"url"` +} + +type responseTask struct { + ID string `json:"id"` + TaskID string `json:"task_id,omitempty"` //兼容旧接口 + Object string `json:"object"` + Model string `json:"model"` + Status string `json:"status"` + Progress int `json:"progress"` + CreatedAt int64 `json:"created_at"` + CompletedAt int64 `json:"completed_at,omitempty"` + ExpiresAt int64 `json:"expires_at,omitempty"` + Seconds string `json:"seconds,omitempty"` + Size string `json:"size,omitempty"` + RemixedFromVideoID string `json:"remixed_from_video_id,omitempty"` + Error *struct { + Message string `json:"message"` + Code string `json:"code"` + } `json:"error,omitempty"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + a.apiKey = info.ApiKey +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + return relaycommon.ValidateMultipartDirect(c, info) +} + +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/v1/videos", a.baseURL), nil +} + +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Authorization", "Bearer "+a.apiKey) + req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + cachedBody, err := common.GetRequestBody(c) + if err != nil { + return nil, errors.Wrap(err, "get_request_body_failed") + } + return bytes.NewReader(cachedBody), nil +} + +// DoRequest delegates to common helper. +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +// DoResponse handles upstream response, returns taskID etc. +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + _ = resp.Body.Close() + + // Parse Sora response + var dResp responseTask + if err := common.Unmarshal(responseBody, &dResp); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + + if dResp.ID == "" { + if dResp.TaskID == "" { + taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError) + return + } + dResp.ID = dResp.TaskID + dResp.TaskID = "" + } + + c.JSON(http.StatusOK, dResp) + return dResp.ID, responseBody, nil +} + +// FetchTask fetch task status +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + + uri := fmt.Sprintf("%s/v1/videos/%s", baseUrl, taskID) + + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+key) + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return ModelList +} + +func (a *TaskAdaptor) GetChannelName() string { + return ChannelName +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + resTask := responseTask{} + if err := common.Unmarshal(respBody, &resTask); err != nil { + return nil, errors.Wrap(err, "unmarshal task result failed") + } + + taskResult := relaycommon.TaskInfo{ + Code: 0, + } + + switch resTask.Status { + case "queued", "pending": + taskResult.Status = model.TaskStatusQueued + case "processing", "in_progress": + taskResult.Status = model.TaskStatusInProgress + case "completed": + taskResult.Status = model.TaskStatusSuccess + taskResult.Url = fmt.Sprintf("%s/v1/videos/%s/content", system_setting.ServerAddress, resTask.ID) + case "failed", "cancelled": + taskResult.Status = model.TaskStatusFailure + if resTask.Error != nil { + taskResult.Reason = resTask.Error.Message + } else { + taskResult.Reason = "task failed" + } + default: + } + if resTask.Progress > 0 && resTask.Progress < 100 { + taskResult.Progress = fmt.Sprintf("%d%%", resTask.Progress) + } + + return &taskResult, nil +} + +func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) { + return task.Data, nil +} diff --git a/relay/channel/task/sora/constants.go b/relay/channel/task/sora/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..e2f6536eafcdc1efa01545876b24fa182be13424 --- /dev/null +++ b/relay/channel/task/sora/constants.go @@ -0,0 +1,8 @@ +package sora + +var ModelList = []string{ + "sora-2", + "sora-2-pro", +} + +var ChannelName = "sora" diff --git a/relay/channel/task/suno/adaptor.go b/relay/channel/task/suno/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..c4858d0c0f53a4da70b955925e3883148dd41082 --- /dev/null +++ b/relay/channel/task/suno/adaptor.go @@ -0,0 +1,178 @@ +package suno + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" +) + +type TaskAdaptor struct { + ChannelType int +} + +func (a *TaskAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { + return nil, fmt.Errorf("not implement") // todo implement this method if needed +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + action := strings.ToUpper(c.Param("action")) + + var sunoRequest *dto.SunoSubmitReq + err := common.UnmarshalBodyReusable(c, &sunoRequest) + if err != nil { + taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + return + } + err = actionValidate(c, sunoRequest, action) + if err != nil { + taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + return + } + + if sunoRequest.ContinueClipId != "" { + if sunoRequest.TaskID == "" { + taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("task id is empty"), "invalid_request", http.StatusBadRequest) + return + } + info.OriginTaskID = sunoRequest.TaskID + } + + info.Action = action + c.Set("task_request", sunoRequest) + return nil +} + +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + baseURL := info.ChannelBaseUrl + fullRequestURL := fmt.Sprintf("%s%s", baseURL, "/suno/submit/"+info.Action) + return fullRequestURL, nil +} + +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) + req.Header.Set("Accept", c.Request.Header.Get("Accept")) + req.Header.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + sunoRequest, ok := c.Get("task_request") + if !ok { + err := common.UnmarshalBodyReusable(c, &sunoRequest) + if err != nil { + return nil, err + } + } + data, err := json.Marshal(sunoRequest) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + var sunoResponse dto.TaskResponse[string] + err = json.Unmarshal(responseBody, &sunoResponse) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + if !sunoResponse.IsSuccess() { + taskErr = service.TaskErrorWrapper(fmt.Errorf(sunoResponse.Message), sunoResponse.Code, http.StatusInternalServerError) + return + } + + for k, v := range resp.Header { + c.Writer.Header().Set(k, v[0]) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + + _, err = io.Copy(c.Writer, bytes.NewBuffer(responseBody)) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError) + return + } + + return sunoResponse.Data, nil, nil +} + +func (a *TaskAdaptor) GetModelList() []string { + return ModelList +} + +func (a *TaskAdaptor) GetChannelName() string { + return ChannelName +} + +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + requestUrl := fmt.Sprintf("%s/suno/fetch", baseUrl) + byteBody, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", requestUrl, bytes.NewBuffer(byteBody)) + if err != nil { + common.SysLog(fmt.Sprintf("Get Task error: %v", err)) + return nil, err + } + defer req.Body.Close() + // 设置超时时间 + timeout := time.Second * 15 + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + // 使用带有超时的 context 创建新的请求 + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + resp, err := service.GetHttpClient().Do(req) + if err != nil { + return nil, err + } + return resp, nil +} + +func actionValidate(c *gin.Context, sunoRequest *dto.SunoSubmitReq, action string) (err error) { + switch action { + case constant.SunoActionMusic: + if sunoRequest.Mv == "" { + sunoRequest.Mv = "chirp-v3-0" + } + case constant.SunoActionLyrics: + if sunoRequest.Prompt == "" { + err = fmt.Errorf("prompt_empty") + return + } + default: + err = fmt.Errorf("invalid_action") + } + return +} diff --git a/relay/channel/task/suno/models.go b/relay/channel/task/suno/models.go new file mode 100644 index 0000000000000000000000000000000000000000..967cf1b1d7c5a7414fb6fe8d54c6251fe1ecebd8 --- /dev/null +++ b/relay/channel/task/suno/models.go @@ -0,0 +1,7 @@ +package suno + +var ModelList = []string{ + "suno_music", "suno_lyrics", +} + +var ChannelName = "suno" diff --git a/relay/channel/task/vertex/adaptor.go b/relay/channel/task/vertex/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..766aec6501e0c92beec0db74063b16278a192f10 --- /dev/null +++ b/relay/channel/task/vertex/adaptor.go @@ -0,0 +1,380 @@ +package vertex + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + vertexcore "github.com/QuantumNous/new-api/relay/channel/vertex" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" +) + +// ============================ +// Request / Response structures +// ============================ + +type requestPayload struct { + Instances []map[string]any `json:"instances"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +type submitResponse struct { + Name string `json:"name"` +} + +type operationVideo struct { + MimeType string `json:"mimeType"` + BytesBase64Encoded string `json:"bytesBase64Encoded"` + Encoding string `json:"encoding"` +} + +type operationResponse struct { + Name string `json:"name"` + Done bool `json:"done"` + Response struct { + Type string `json:"@type"` + RaiMediaFilteredCount int `json:"raiMediaFilteredCount"` + Videos []operationVideo `json:"videos"` + BytesBase64Encoded string `json:"bytesBase64Encoded"` + Encoding string `json:"encoding"` + Video string `json:"video"` + } `json:"response"` + Error struct { + Message string `json:"message"` + } `json:"error"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + a.apiKey = info.ApiKey +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + // Use the standard validation method for TaskSubmitReq + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionTextGenerate) +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + adc := &vertexcore.Credentials{} + if err := json.Unmarshal([]byte(a.apiKey), adc); err != nil { + return "", fmt.Errorf("failed to decode credentials: %w", err) + } + modelName := info.OriginModelName + if modelName == "" { + modelName = "veo-3.0-generate-001" + } + + region := vertexcore.GetModelRegion(info.ApiVersion, modelName) + if strings.TrimSpace(region) == "" { + region = "global" + } + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:predictLongRunning", + adc.ProjectID, + modelName, + ), nil + } + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning", + region, + adc.ProjectID, + region, + modelName, + ), nil +} + +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + adc := &vertexcore.Credentials{} + if err := json.Unmarshal([]byte(a.apiKey), adc); err != nil { + return fmt.Errorf("failed to decode credentials: %w", err) + } + + token, err := vertexcore.AcquireAccessToken(*adc, "") + if err != nil { + return fmt.Errorf("failed to acquire access token: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("x-goog-user-project", adc.ProjectID) + return nil +} + +// BuildRequestBody converts request into Vertex specific format. +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + v, ok := c.Get("task_request") + if !ok { + return nil, fmt.Errorf("request not found in context") + } + req := v.(relaycommon.TaskSubmitReq) + + body := requestPayload{ + Instances: []map[string]any{{"prompt": req.Prompt}}, + Parameters: map[string]any{}, + } + if req.Metadata != nil { + if v, ok := req.Metadata["storageUri"]; ok { + body.Parameters["storageUri"] = v + } + if v, ok := req.Metadata["sampleCount"]; ok { + body.Parameters["sampleCount"] = v + } + } + if _, ok := body.Parameters["sampleCount"]; !ok { + body.Parameters["sampleCount"] = 1 + } + + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +// DoRequest delegates to common helper. +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +// DoResponse handles upstream response, returns taskID etc. +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + } + _ = resp.Body.Close() + + var s submitResponse + if err := json.Unmarshal(responseBody, &s); err != nil { + return "", nil, service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError) + } + if strings.TrimSpace(s.Name) == "" { + return "", nil, service.TaskErrorWrapper(fmt.Errorf("missing operation name"), "invalid_response", http.StatusInternalServerError) + } + localID := encodeLocalTaskID(s.Name) + c.JSON(http.StatusOK, gin.H{"task_id": localID}) + return localID, responseBody, nil +} + +func (a *TaskAdaptor) GetModelList() []string { return []string{"veo-3.0-generate-001"} } +func (a *TaskAdaptor) GetChannelName() string { return "vertex" } + +// FetchTask fetch task status +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + upstreamName, err := decodeLocalTaskID(taskID) + if err != nil { + return nil, fmt.Errorf("decode task_id failed: %w", err) + } + region := extractRegionFromOperationName(upstreamName) + if region == "" { + region = "us-central1" + } + project := extractProjectFromOperationName(upstreamName) + modelName := extractModelFromOperationName(upstreamName) + if project == "" || modelName == "" { + return nil, fmt.Errorf("cannot extract project/model from operation name") + } + var url string + if region == "global" { + url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:fetchPredictOperation", project, modelName) + } else { + url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", region, project, region, modelName) + } + payload := map[string]string{"operationName": upstreamName} + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + adc := &vertexcore.Credentials{} + if err := json.Unmarshal([]byte(key), adc); err != nil { + return nil, fmt.Errorf("failed to decode credentials: %w", err) + } + token, err := vertexcore.AcquireAccessToken(*adc, "") + if err != nil { + return nil, fmt.Errorf("failed to acquire access token: %w", err) + } + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("x-goog-user-project", adc.ProjectID) + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + var op operationResponse + if err := json.Unmarshal(respBody, &op); err != nil { + return nil, fmt.Errorf("unmarshal operation response failed: %w", err) + } + ti := &relaycommon.TaskInfo{} + if op.Error.Message != "" { + ti.Status = model.TaskStatusFailure + ti.Reason = op.Error.Message + ti.Progress = "100%" + return ti, nil + } + if !op.Done { + ti.Status = model.TaskStatusInProgress + ti.Progress = "50%" + return ti, nil + } + ti.Status = model.TaskStatusSuccess + ti.Progress = "100%" + if len(op.Response.Videos) > 0 { + v0 := op.Response.Videos[0] + if v0.BytesBase64Encoded != "" { + mime := strings.TrimSpace(v0.MimeType) + if mime == "" { + enc := strings.TrimSpace(v0.Encoding) + if enc == "" { + enc = "mp4" + } + if strings.Contains(enc, "/") { + mime = enc + } else { + mime = "video/" + enc + } + } + ti.Url = "data:" + mime + ";base64," + v0.BytesBase64Encoded + return ti, nil + } + } + if op.Response.BytesBase64Encoded != "" { + enc := strings.TrimSpace(op.Response.Encoding) + if enc == "" { + enc = "mp4" + } + mime := enc + if !strings.Contains(enc, "/") { + mime = "video/" + enc + } + ti.Url = "data:" + mime + ";base64," + op.Response.BytesBase64Encoded + return ti, nil + } + if op.Response.Video != "" { // some variants use `video` as base64 + enc := strings.TrimSpace(op.Response.Encoding) + if enc == "" { + enc = "mp4" + } + mime := enc + if !strings.Contains(enc, "/") { + mime = "video/" + enc + } + ti.Url = "data:" + mime + ";base64," + op.Response.Video + return ti, nil + } + return ti, nil +} + +func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) { + upstreamName, err := decodeLocalTaskID(task.TaskID) + if err != nil { + upstreamName = "" + } + modelName := extractModelFromOperationName(upstreamName) + if strings.TrimSpace(modelName) == "" { + modelName = "veo-3.0-generate-001" + } + v := dto.NewOpenAIVideo() + v.ID = task.TaskID + v.Model = modelName + v.Status = task.Status.ToVideoStatus() + v.SetProgressStr(task.Progress) + v.CreatedAt = task.CreatedAt + v.CompletedAt = task.UpdatedAt + if strings.HasPrefix(task.FailReason, "data:") && len(task.FailReason) > 0 { + v.SetMetadata("url", task.FailReason) + } + + return common.Marshal(v) +} + +// ============================ +// helpers +// ============================ + +func encodeLocalTaskID(name string) string { + return base64.RawURLEncoding.EncodeToString([]byte(name)) +} + +func decodeLocalTaskID(local string) (string, error) { + b, err := base64.RawURLEncoding.DecodeString(local) + if err != nil { + return "", err + } + return string(b), nil +} + +var regionRe = regexp.MustCompile(`locations/([a-z0-9-]+)/`) + +func extractRegionFromOperationName(name string) string { + m := regionRe.FindStringSubmatch(name) + if len(m) == 2 { + return m[1] + } + return "" +} + +var modelRe = regexp.MustCompile(`models/([^/]+)/operations/`) + +func extractModelFromOperationName(name string) string { + m := modelRe.FindStringSubmatch(name) + if len(m) == 2 { + return m[1] + } + idx := strings.Index(name, "models/") + if idx >= 0 { + s := name[idx+len("models/"):] + if p := strings.Index(s, "/operations/"); p > 0 { + return s[:p] + } + } + return "" +} + +var projectRe = regexp.MustCompile(`projects/([^/]+)/locations/`) + +func extractProjectFromOperationName(name string) string { + m := projectRe.FindStringSubmatch(name) + if len(m) == 2 { + return m[1] + } + return "" +} diff --git a/relay/channel/task/vidu/adaptor.go b/relay/channel/task/vidu/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..6b62f1f018f30a0f59101c2ec14aa1e1d860d484 --- /dev/null +++ b/relay/channel/task/vidu/adaptor.go @@ -0,0 +1,316 @@ +package vidu + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + + "github.com/pkg/errors" +) + +// ============================ +// Request / Response structures +// ============================ + +type requestPayload struct { + Model string `json:"model"` + Images []string `json:"images"` + Prompt string `json:"prompt,omitempty"` + Duration int `json:"duration,omitempty"` + Seed int `json:"seed,omitempty"` + Resolution string `json:"resolution,omitempty"` + MovementAmplitude string `json:"movement_amplitude,omitempty"` + Bgm bool `json:"bgm,omitempty"` + Payload string `json:"payload,omitempty"` + CallbackUrl string `json:"callback_url,omitempty"` +} + +type responsePayload struct { + TaskId string `json:"task_id"` + State string `json:"state"` + Model string `json:"model"` + Images []string `json:"images"` + Prompt string `json:"prompt"` + Duration int `json:"duration"` + Seed int `json:"seed"` + Resolution string `json:"resolution"` + Bgm bool `json:"bgm"` + MovementAmplitude string `json:"movement_amplitude"` + Payload string `json:"payload"` + CreatedAt string `json:"created_at"` +} + +type taskResultResponse struct { + State string `json:"state"` + ErrCode string `json:"err_code"` + Credits int `json:"credits"` + Payload string `json:"payload"` + Creations []creation `json:"creations"` +} + +type creation struct { + ID string `json:"id"` + URL string `json:"url"` + CoverURL string `json:"cover_url"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { + if err := relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate); err != nil { + return err + } + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return service.TaskErrorWrapper(err, "get_task_request_failed", http.StatusBadRequest) + } + action := constant.TaskActionTextGenerate + if meatAction, ok := req.Metadata["action"]; ok { + action, _ = meatAction.(string) + } else if req.HasImage() { + action = constant.TaskActionGenerate + if info.ChannelType == constant.ChannelTypeVidu { + // vidu 增加 首尾帧生视频和参考图生视频 + if len(req.Images) == 2 { + action = constant.TaskActionFirstTailGenerate + } else if len(req.Images) > 2 { + action = constant.TaskActionReferenceGenerate + } + } + } + info.Action = action + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + v, exists := c.Get("task_request") + if !exists { + return nil, fmt.Errorf("request not found in context") + } + req := v.(relaycommon.TaskSubmitReq) + + body, err := a.convertToRequestPayload(&req) + if err != nil { + return nil, err + } + + if info.Action == constant.TaskActionReferenceGenerate { + if strings.Contains(body.Model, "viduq2") { + // 参考图生视频只能用 viduq2 模型, 不能带有pro或turbo后缀 https://platform.vidu.cn/docs/reference-to-video + body.Model = "viduq2" + } + } + + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + var path string + switch info.Action { + case constant.TaskActionGenerate: + path = "/img2video" + case constant.TaskActionFirstTailGenerate: + path = "/start-end2video" + case constant.TaskActionReferenceGenerate: + path = "/reference2video" + default: + path = "/text2video" + } + return fmt.Sprintf("%s/ent/v2%s", a.baseURL, path), nil +} + +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Token "+info.ApiKey) + return nil +} + +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + + var vResp responsePayload + err = json.Unmarshal(responseBody, &vResp) + if err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrap(err, fmt.Sprintf("%s", responseBody)), "unmarshal_response_failed", http.StatusInternalServerError) + return + } + + if vResp.State == "failed" { + taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("task failed"), "task_failed", http.StatusBadRequest) + return + } + + ov := dto.NewOpenAIVideo() + ov.ID = vResp.TaskId + ov.TaskID = vResp.TaskId + ov.CreatedAt = time.Now().Unix() + ov.Model = info.OriginModelName + c.JSON(http.StatusOK, ov) + return vResp.TaskId, responseBody, nil +} + +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + + url := fmt.Sprintf("%s/ent/v2/tasks/%s/creations", baseUrl, taskID) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Token "+key) + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return []string{"viduq2", "viduq1", "vidu2.0", "vidu1.5"} +} + +func (a *TaskAdaptor) GetChannelName() string { + return "vidu" +} + +// ============================ +// helpers +// ============================ + +func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { + r := requestPayload{ + Model: defaultString(req.Model, "viduq1"), + Images: req.Images, + Prompt: req.Prompt, + Duration: defaultInt(req.Duration, 5), + Resolution: defaultString(req.Size, "1080p"), + MovementAmplitude: "auto", + Bgm: false, + } + metadata := req.Metadata + medaBytes, err := json.Marshal(metadata) + if err != nil { + return nil, errors.Wrap(err, "metadata marshal metadata failed") + } + err = json.Unmarshal(medaBytes, &r) + if err != nil { + return nil, errors.Wrap(err, "unmarshal metadata failed") + } + return &r, nil +} + +func defaultString(value, defaultValue string) string { + if value == "" { + return defaultValue + } + return value +} + +func defaultInt(value, defaultValue int) int { + if value == 0 { + return defaultValue + } + return value +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + taskInfo := &relaycommon.TaskInfo{} + + var taskResp taskResultResponse + err := json.Unmarshal(respBody, &taskResp) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal response body") + } + + state := taskResp.State + switch state { + case "created", "queueing": + taskInfo.Status = model.TaskStatusSubmitted + case "processing": + taskInfo.Status = model.TaskStatusInProgress + case "success": + taskInfo.Status = model.TaskStatusSuccess + if len(taskResp.Creations) > 0 { + taskInfo.Url = taskResp.Creations[0].URL + } + case "failed": + taskInfo.Status = model.TaskStatusFailure + if taskResp.ErrCode != "" { + taskInfo.Reason = taskResp.ErrCode + } + default: + return nil, fmt.Errorf("unknown task state: %s", state) + } + + return taskInfo, nil +} + +func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) { + var viduResp taskResultResponse + if err := json.Unmarshal(originTask.Data, &viduResp); err != nil { + return nil, errors.Wrap(err, "unmarshal vidu task data failed") + } + + openAIVideo := dto.NewOpenAIVideo() + openAIVideo.ID = originTask.TaskID + openAIVideo.Status = originTask.Status.ToVideoStatus() + openAIVideo.SetProgressStr(originTask.Progress) + openAIVideo.CreatedAt = originTask.CreatedAt + openAIVideo.CompletedAt = originTask.UpdatedAt + + if len(viduResp.Creations) > 0 && viduResp.Creations[0].URL != "" { + openAIVideo.SetMetadata("url", viduResp.Creations[0].URL) + } + + if viduResp.State == "failed" && viduResp.ErrCode != "" { + openAIVideo.Error = &dto.OpenAIVideoError{ + Message: viduResp.ErrCode, + Code: viduResp.ErrCode, + } + } + + jsonData, _ := common.Marshal(openAIVideo) + return jsonData, nil +} diff --git a/relay/channel/tencent/adaptor.go b/relay/channel/tencent/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..eb698553771b3b6d67c2d88e93795f4984c71ac9 --- /dev/null +++ b/relay/channel/tencent/adaptor.go @@ -0,0 +1,119 @@ +package tencent + +import ( + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { + Sign string + AppID int64 + Action string + Version string + Timestamp int64 +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + a.Action = "ChatCompletions" + a.Version = "2023-09-01" + a.Timestamp = common.GetTimestamp() +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/", info.ChannelBaseUrl), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", a.Sign) + req.Set("X-TC-Action", a.Action) + req.Set("X-TC-Version", a.Version) + req.Set("X-TC-Timestamp", strconv.FormatInt(a.Timestamp, 10)) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + apiKey := common.GetContextKeyString(c, constant.ContextKeyChannelKey) + apiKey = strings.TrimPrefix(apiKey, "Bearer ") + appId, secretId, secretKey, err := parseTencentConfig(apiKey) + a.AppID = appId + if err != nil { + return nil, err + } + tencentRequest := requestOpenAI2Tencent(a, *request) + // we have to calculate the sign here + a.Sign = getTencentSign(*tencentRequest, a, secretId, secretKey) + return tencentRequest, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = tencentStreamHandler(c, info, resp) + } else { + usage, err = tencentHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/tencent/constants.go b/relay/channel/tencent/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..d4d9cc1f4a4fd7ac4477d668fc8cb7e413f57c5c --- /dev/null +++ b/relay/channel/tencent/constants.go @@ -0,0 +1,10 @@ +package tencent + +var ModelList = []string{ + "hunyuan-lite", + "hunyuan-standard", + "hunyuan-standard-256K", + "hunyuan-pro", +} + +var ChannelName = "tencent" diff --git a/relay/channel/tencent/dto.go b/relay/channel/tencent/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..65c548a964f282fc80f5efe9b7ba48eaf3fe7b45 --- /dev/null +++ b/relay/channel/tencent/dto.go @@ -0,0 +1,75 @@ +package tencent + +type TencentMessage struct { + Role string `json:"Role"` + Content string `json:"Content"` +} + +type TencentChatRequest struct { + // 模型名称,可选值包括 hunyuan-lite、hunyuan-standard、hunyuan-standard-256K、hunyuan-pro。 + // 各模型介绍请阅读 [产品概述](https://cloud.tencent.com/document/product/1729/104753) 中的说明。 + // + // 注意: + // 不同的模型计费不同,请根据 [购买指南](https://cloud.tencent.com/document/product/1729/97731) 按需调用。 + Model *string `json:"Model"` + // 聊天上下文信息。 + // 说明: + // 1. 长度最多为 40,按对话时间从旧到新在数组中排列。 + // 2. Message.Role 可选值:system、user、assistant。 + // 其中,system 角色可选,如存在则必须位于列表的最开始。user 和 assistant 需交替出现(一问一答),以 user 提问开始和结束,且 Content 不能为空。Role 的顺序示例:[system(可选) user assistant user assistant user ...]。 + // 3. Messages 中 Content 总长度不能超过模型输入长度上限(可参考 [产品概述](https://cloud.tencent.com/document/product/1729/104753) 文档),超过则会截断最前面的内容,只保留尾部内容。 + Messages []*TencentMessage `json:"Messages"` + // 流式调用开关。 + // 说明: + // 1. 未传值时默认为非流式调用(false)。 + // 2. 流式调用时以 SSE 协议增量返回结果(返回值取 Choices[n].Delta 中的值,需要拼接增量数据才能获得完整结果)。 + // 3. 非流式调用时: + // 调用方式与普通 HTTP 请求无异。 + // 接口响应耗时较长,**如需更低时延建议设置为 true**。 + // 只返回一次最终结果(返回值取 Choices[n].Message 中的值)。 + // + // 注意: + // 通过 SDK 调用时,流式和非流式调用需用**不同的方式**获取返回值,具体参考 SDK 中的注释或示例(在各语言 SDK 代码仓库的 examples/hunyuan/v20230901/ 目录中)。 + Stream *bool `json:"Stream,omitempty"` + // 说明: + // 1. 影响输出文本的多样性,取值越大,生成文本的多样性越强。 + // 2. 取值区间为 [0.0, 1.0],未传值时使用各模型推荐值。 + // 3. 非必要不建议使用,不合理的取值会影响效果。 + TopP *float64 `json:"TopP,omitempty"` + // 说明: + // 1. 较高的数值会使输出更加随机,而较低的数值会使其更加集中和确定。 + // 2. 取值区间为 [0.0, 2.0],未传值时使用各模型推荐值。 + // 3. 非必要不建议使用,不合理的取值会影响效果。 + Temperature *float64 `json:"Temperature,omitempty"` +} + +type TencentError struct { + Code int `json:"Code"` + Message string `json:"Message"` +} + +type TencentUsage struct { + PromptTokens int `json:"PromptTokens"` + CompletionTokens int `json:"CompletionTokens"` + TotalTokens int `json:"TotalTokens"` +} + +type TencentResponseChoices struct { + FinishReason string `json:"FinishReason,omitempty"` // 流式结束标志位,为 stop 则表示尾包 + Messages TencentMessage `json:"Message,omitempty"` // 内容,同步模式返回内容,流模式为 null 输出 content 内容总数最多支持 1024token。 + Delta TencentMessage `json:"Delta,omitempty"` // 内容,流模式返回内容,同步模式为 null 输出 content 内容总数最多支持 1024token。 +} + +type TencentChatResponse struct { + Choices []TencentResponseChoices `json:"Choices,omitempty"` // 结果 + Created int64 `json:"Created,omitempty"` // unix 时间戳的字符串 + Id string `json:"Id,omitempty"` // 会话 id + Usage TencentUsage `json:"Usage,omitempty"` // token 数量 + Error TencentError `json:"Error,omitempty"` // 错误信息 注意:此字段可能返回 null,表示取不到有效值 + Note string `json:"Note,omitempty"` // 注释 + ReqID string `json:"Req_id,omitempty"` // 唯一请求 Id,每次请求都会返回。用于反馈接口入参 +} + +type TencentChatResponseSB struct { + Response TencentChatResponse `json:"Response,omitempty"` +} diff --git a/relay/channel/tencent/relay-tencent.go b/relay/channel/tencent/relay-tencent.go new file mode 100644 index 0000000000000000000000000000000000000000..77192c0d2fb8a6de357afc139823068e5fa77f25 --- /dev/null +++ b/relay/channel/tencent/relay-tencent.go @@ -0,0 +1,234 @@ +package tencent + +import ( + "bufio" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +// https://cloud.tencent.com/document/product/1729/97732 + +func requestOpenAI2Tencent(a *Adaptor, request dto.GeneralOpenAIRequest) *TencentChatRequest { + messages := make([]*TencentMessage, 0, len(request.Messages)) + for i := 0; i < len(request.Messages); i++ { + message := request.Messages[i] + messages = append(messages, &TencentMessage{ + Content: message.StringContent(), + Role: message.Role, + }) + } + var req = TencentChatRequest{ + Stream: &request.Stream, + Messages: messages, + Model: &request.Model, + } + if request.TopP != 0 { + req.TopP = &request.TopP + } + req.Temperature = request.Temperature + return &req +} + +func responseTencent2OpenAI(response *TencentChatResponse) *dto.OpenAITextResponse { + fullTextResponse := dto.OpenAITextResponse{ + Id: response.Id, + Object: "chat.completion", + Created: common.GetTimestamp(), + Usage: dto.Usage{ + PromptTokens: response.Usage.PromptTokens, + CompletionTokens: response.Usage.CompletionTokens, + TotalTokens: response.Usage.TotalTokens, + }, + } + if len(response.Choices) > 0 { + choice := dto.OpenAITextResponseChoice{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: response.Choices[0].Messages.Content, + }, + FinishReason: response.Choices[0].FinishReason, + } + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + } + return &fullTextResponse +} + +func streamResponseTencent2OpenAI(TencentResponse *TencentChatResponse) *dto.ChatCompletionsStreamResponse { + response := dto.ChatCompletionsStreamResponse{ + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "tencent-hunyuan", + } + if len(TencentResponse.Choices) > 0 { + var choice dto.ChatCompletionsStreamResponseChoice + choice.Delta.SetContentString(TencentResponse.Choices[0].Delta.Content) + if TencentResponse.Choices[0].FinishReason == "stop" { + choice.FinishReason = &constant.FinishReasonStop + } + response.Choices = append(response.Choices, choice) + } + return &response +} + +func tencentStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var responseText string + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + + helper.SetEventStreamHeaders(c) + + for scanner.Scan() { + data := scanner.Text() + if len(data) < 5 || !strings.HasPrefix(data, "data:") { + continue + } + data = strings.TrimPrefix(data, "data:") + + var tencentResponse TencentChatResponse + err := json.Unmarshal([]byte(data), &tencentResponse) + if err != nil { + common.SysLog("error unmarshalling stream response: " + err.Error()) + continue + } + + response := streamResponseTencent2OpenAI(&tencentResponse) + if len(response.Choices) != 0 { + responseText += response.Choices[0].Delta.GetContentString() + } + + err = helper.ObjectData(c, response) + if err != nil { + common.SysLog(err.Error()) + } + } + + if err := scanner.Err(); err != nil { + common.SysLog("error reading stream: " + err.Error()) + } + + helper.Done(c) + + service.CloseResponseBodyGracefully(resp) + + return service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens), nil +} + +func tencentHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var tencentSb TencentChatResponseSB + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &tencentSb) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if tencentSb.Response.Error.Code != 0 { + return nil, types.WithOpenAIError(types.OpenAIError{ + Message: tencentSb.Response.Error.Message, + Code: tencentSb.Response.Error.Code, + }, resp.StatusCode) + } + fullTextResponse := responseTencent2OpenAI(&tencentSb.Response) + jsonResponse, err := common.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + service.IOCopyBytesGracefully(c, resp, jsonResponse) + return &fullTextResponse.Usage, nil +} + +func parseTencentConfig(config string) (appId int64, secretId string, secretKey string, err error) { + parts := strings.Split(config, "|") + if len(parts) != 3 { + err = errors.New("invalid tencent config") + return + } + appId, err = strconv.ParseInt(parts[0], 10, 64) + secretId = parts[1] + secretKey = parts[2] + return +} + +func sha256hex(s string) string { + b := sha256.Sum256([]byte(s)) + return hex.EncodeToString(b[:]) +} + +func hmacSha256(s, key string) string { + hashed := hmac.New(sha256.New, []byte(key)) + hashed.Write([]byte(s)) + return string(hashed.Sum(nil)) +} + +func getTencentSign(req TencentChatRequest, adaptor *Adaptor, secId, secKey string) string { + // build canonical request string + host := "hunyuan.tencentcloudapi.com" + httpRequestMethod := "POST" + canonicalURI := "/" + canonicalQueryString := "" + canonicalHeaders := fmt.Sprintf("content-type:%s\nhost:%s\nx-tc-action:%s\n", + "application/json", host, strings.ToLower(adaptor.Action)) + signedHeaders := "content-type;host;x-tc-action" + payload, _ := json.Marshal(req) + hashedRequestPayload := sha256hex(string(payload)) + canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", + httpRequestMethod, + canonicalURI, + canonicalQueryString, + canonicalHeaders, + signedHeaders, + hashedRequestPayload) + // build string to sign + algorithm := "TC3-HMAC-SHA256" + requestTimestamp := strconv.FormatInt(adaptor.Timestamp, 10) + timestamp, _ := strconv.ParseInt(requestTimestamp, 10, 64) + t := time.Unix(timestamp, 0).UTC() + // must be the format 2006-01-02, ref to package time for more info + date := t.Format("2006-01-02") + credentialScope := fmt.Sprintf("%s/%s/tc3_request", date, "hunyuan") + hashedCanonicalRequest := sha256hex(canonicalRequest) + string2sign := fmt.Sprintf("%s\n%s\n%s\n%s", + algorithm, + requestTimestamp, + credentialScope, + hashedCanonicalRequest) + + // sign string + secretDate := hmacSha256(date, "TC3"+secKey) + secretService := hmacSha256("hunyuan", secretDate) + secretKey := hmacSha256("tc3_request", secretService) + signature := hex.EncodeToString([]byte(hmacSha256(string2sign, secretKey))) + + // build authorization + authorization := fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", + algorithm, + secId, + credentialScope, + signedHeaders, + signature) + return authorization +} diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..920041ce6f90d5cd71db518d1adc2b5f18ca993f --- /dev/null +++ b/relay/channel/vertex/adaptor.go @@ -0,0 +1,383 @@ +package vertex + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/claude" + "github.com/QuantumNous/new-api/relay/channel/gemini" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +const ( + RequestModeClaude = 1 + RequestModeGemini = 2 + RequestModeLlama = 3 +) + +var claudeModelMap = map[string]string{ + "claude-3-sonnet-20240229": "claude-3-sonnet@20240229", + "claude-3-opus-20240229": "claude-3-opus@20240229", + "claude-3-haiku-20240307": "claude-3-haiku@20240307", + "claude-3-5-sonnet-20240620": "claude-3-5-sonnet@20240620", + "claude-3-5-sonnet-20241022": "claude-3-5-sonnet-v2@20241022", + "claude-3-7-sonnet-20250219": "claude-3-7-sonnet@20250219", + "claude-sonnet-4-20250514": "claude-sonnet-4@20250514", + "claude-opus-4-20250514": "claude-opus-4@20250514", + "claude-opus-4-1-20250805": "claude-opus-4-1@20250805", + "claude-sonnet-4-5-20250929": "claude-sonnet-4-5@20250929", + "claude-opus-4-5-20251101": "claude-opus-4-5@20251101", +} + +const anthropicVersion = "vertex-2023-10-16" + +type Adaptor struct { + RequestMode int + AccountCredentials Credentials +} + +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + geminiAdaptor := gemini.Adaptor{} + return geminiAdaptor.ConvertGeminiRequest(c, info, request) +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + if v, ok := claudeModelMap[info.UpstreamModelName]; ok { + c.Set("request_model", v) + } else { + c.Set("request_model", request.Model) + } + vertexClaudeReq := copyRequest(request, anthropicVersion) + return vertexClaudeReq, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + geminiAdaptor := gemini.Adaptor{} + return geminiAdaptor.ConvertImageRequest(c, info, request) +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + if strings.HasPrefix(info.UpstreamModelName, "claude") { + a.RequestMode = RequestModeClaude + } else if strings.Contains(info.UpstreamModelName, "llama") || + // open source models + strings.Contains(info.UpstreamModelName, "-maas") { + a.RequestMode = RequestModeLlama + } else { + a.RequestMode = RequestModeGemini + } +} + +func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix string) (string, error) { + region := GetModelRegion(info.ApiVersion, info.OriginModelName) + if info.ChannelOtherSettings.VertexKeyType != dto.VertexKeyTypeAPIKey { + adc := &Credentials{} + if err := common.Unmarshal([]byte(info.ApiKey), adc); err != nil { + return "", fmt.Errorf("failed to decode credentials file: %w", err) + } + a.AccountCredentials = *adc + + if a.RequestMode == RequestModeGemini { + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", + adc.ProjectID, + modelName, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", + region, + adc.ProjectID, + region, + modelName, + suffix, + ), nil + } + } else if a.RequestMode == RequestModeClaude { + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s", + adc.ProjectID, + modelName, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s", + region, + adc.ProjectID, + region, + modelName, + suffix, + ), nil + } + } else if a.RequestMode == RequestModeLlama { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions", + region, + adc.ProjectID, + region, + ), nil + } + } else { + var keyPrefix string + if strings.HasSuffix(suffix, "?alt=sse") { + keyPrefix = "&" + } else { + keyPrefix = "?" + } + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s", + modelName, + suffix, + keyPrefix, + info.ApiKey, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s", + region, + modelName, + suffix, + keyPrefix, + info.ApiKey, + ), nil + } + } + return "", errors.New("unsupported request mode") +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + suffix := "" + if a.RequestMode == RequestModeGemini { + if model_setting.GetGeminiSettings().ThinkingAdapterEnabled && + !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) { + // 新增逻辑:处理 -thinking- 格式 + if strings.Contains(info.UpstreamModelName, "-thinking-") { + parts := strings.Split(info.UpstreamModelName, "-thinking-") + info.UpstreamModelName = parts[0] + } else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配 + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") + } else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking") + } + } + + if info.IsStream { + suffix = "streamGenerateContent?alt=sse" + } else { + suffix = "generateContent" + } + + if strings.HasPrefix(info.UpstreamModelName, "imagen") { + suffix = "predict" + } + return a.getRequestUrl(info, info.UpstreamModelName, suffix) + } else if a.RequestMode == RequestModeClaude { + if info.IsStream { + suffix = "streamRawPredict?alt=sse" + } else { + suffix = "rawPredict" + } + model := info.UpstreamModelName + if v, ok := claudeModelMap[info.UpstreamModelName]; ok { + model = v + } + return a.getRequestUrl(info, model, suffix) + } else if a.RequestMode == RequestModeLlama { + return a.getRequestUrl(info, "", "") + } + return "", errors.New("unsupported request mode") +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + if info.ChannelOtherSettings.VertexKeyType != dto.VertexKeyTypeAPIKey { + accessToken, err := getAccessToken(a, info) + if err != nil { + return err + } + req.Set("Authorization", "Bearer "+accessToken) + } + if a.AccountCredentials.ProjectID != "" { + req.Set("x-goog-user-project", a.AccountCredentials.ProjectID) + } + if strings.Contains(info.UpstreamModelName, "claude") { + claude.CommonClaudeHeadersOperation(c, req, info) + } + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if a.RequestMode == RequestModeGemini && strings.HasPrefix(info.UpstreamModelName, "imagen") { + prompt := "" + for _, m := range request.Messages { + if m.Role == "user" { + prompt = m.StringContent() + if prompt != "" { + break + } + } + } + if prompt == "" { + if p, ok := request.Prompt.(string); ok { + prompt = p + } + } + if prompt == "" { + return nil, errors.New("prompt is required for image generation") + } + + imgReq := dto.ImageRequest{ + Model: request.Model, + Prompt: prompt, + N: 1, + Size: "1024x1024", + } + if request.N > 0 { + imgReq.N = uint(request.N) + } + if request.Size != "" { + imgReq.Size = request.Size + } + if len(request.ExtraBody) > 0 { + var extra map[string]any + if err := json.Unmarshal(request.ExtraBody, &extra); err == nil { + if n, ok := extra["n"].(float64); ok && n > 0 { + imgReq.N = uint(n) + } + if size, ok := extra["size"].(string); ok { + imgReq.Size = size + } + // accept aspectRatio in extra body (top-level or under parameters) + if ar, ok := extra["aspectRatio"].(string); ok && ar != "" { + imgReq.Size = ar + } + if params, ok := extra["parameters"].(map[string]any); ok { + if ar, ok := params["aspectRatio"].(string); ok && ar != "" { + imgReq.Size = ar + } + } + } + } + c.Set("request_model", request.Model) + return a.ConvertImageRequest(c, info, imgReq) + } + if a.RequestMode == RequestModeClaude { + claudeReq, err := claude.RequestOpenAI2ClaudeMessage(c, *request) + if err != nil { + return nil, err + } + vertexClaudeReq := copyRequest(claudeReq, anthropicVersion) + c.Set("request_model", claudeReq.Model) + info.UpstreamModelName = claudeReq.Model + return vertexClaudeReq, nil + } else if a.RequestMode == RequestModeGemini { + geminiRequest, err := gemini.CovertOpenAI2Gemini(c, *request, info) + if err != nil { + return nil, err + } + c.Set("request_model", request.Model) + return geminiRequest, nil + } else if a.RequestMode == RequestModeLlama { + return request, nil + } + return nil, errors.New("unsupported request mode") +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + switch a.RequestMode { + case RequestModeClaude: + return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + case RequestModeGemini: + if info.RelayMode == constant.RelayModeGemini { + return gemini.GeminiTextGenerationStreamHandler(c, info, resp) + } else { + return gemini.GeminiChatStreamHandler(c, info, resp) + } + case RequestModeLlama: + return openai.OaiStreamHandler(c, info, resp) + } + } else { + switch a.RequestMode { + case RequestModeClaude: + return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) + case RequestModeGemini: + if info.RelayMode == constant.RelayModeGemini { + return gemini.GeminiTextGenerationHandler(c, info, resp) + } else { + if strings.HasPrefix(info.UpstreamModelName, "imagen") { + return gemini.GeminiImageHandler(c, info, resp) + } + return gemini.GeminiChatHandler(c, info, resp) + } + case RequestModeLlama: + return openai.OpenaiHandler(c, info, resp) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + var modelList []string + for i, s := range ModelList { + modelList = append(modelList, s) + ModelList[i] = s + } + for i, s := range claude.ModelList { + modelList = append(modelList, s) + claude.ModelList[i] = s + } + for i, s := range gemini.ModelList { + modelList = append(modelList, s) + gemini.ModelList[i] = s + } + return modelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/vertex/constants.go b/relay/channel/vertex/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..c39e23d1bf3d0567d747426dfe42e33b9b59bf91 --- /dev/null +++ b/relay/channel/vertex/constants.go @@ -0,0 +1,15 @@ +package vertex + +var ModelList = []string{ + //"claude-3-sonnet-20240229", + //"claude-3-opus-20240229", + //"claude-3-haiku-20240307", + //"claude-3-5-sonnet-20240620", + + //"gemini-1.5-pro-latest", "gemini-1.5-flash-latest", + //"gemini-1.5-pro-001", "gemini-1.5-flash-001", "gemini-pro", "gemini-pro-vision", + + "meta/llama3-405b-instruct-maas", +} + +var ChannelName = "vertex-ai" diff --git a/relay/channel/vertex/dto.go b/relay/channel/vertex/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..68044ff328b1dac1bc096257214b3a483088ed3a --- /dev/null +++ b/relay/channel/vertex/dto.go @@ -0,0 +1,37 @@ +package vertex + +import ( + "github.com/QuantumNous/new-api/dto" +) + +type VertexAIClaudeRequest struct { + AnthropicVersion string `json:"anthropic_version"` + Messages []dto.ClaudeMessage `json:"messages"` + System any `json:"system,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + Tools any `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Thinking *dto.Thinking `json:"thinking,omitempty"` +} + +func copyRequest(req *dto.ClaudeRequest, version string) *VertexAIClaudeRequest { + return &VertexAIClaudeRequest{ + AnthropicVersion: version, + System: req.System, + Messages: req.Messages, + MaxTokens: req.MaxTokens, + Stream: req.Stream, + Temperature: req.Temperature, + TopP: req.TopP, + TopK: req.TopK, + StopSequences: req.StopSequences, + Tools: req.Tools, + ToolChoice: req.ToolChoice, + Thinking: req.Thinking, + } +} diff --git a/relay/channel/vertex/relay-vertex.go b/relay/channel/vertex/relay-vertex.go new file mode 100644 index 0000000000000000000000000000000000000000..c5103a977ecb2374432c60d32f552ec327e083fb --- /dev/null +++ b/relay/channel/vertex/relay-vertex.go @@ -0,0 +1,22 @@ +package vertex + +import "github.com/QuantumNous/new-api/common" + +func GetModelRegion(other string, localModelName string) string { + // if other is json string + if common.IsJsonObject(other) { + m, err := common.StrToMap(other) + if err != nil { + return other // return original if parsing fails + } + if m[localModelName] != nil { + return m[localModelName].(string) + } else { + if v, ok := m["default"]; ok { + return v.(string) + } + return "global" + } + } + return other +} diff --git a/relay/channel/vertex/service_account.go b/relay/channel/vertex/service_account.go new file mode 100644 index 0000000000000000000000000000000000000000..96ec6b28f49c7572a0ee52ca2b5e4961a8119898 --- /dev/null +++ b/relay/channel/vertex/service_account.go @@ -0,0 +1,183 @@ +package vertex + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "net/http" + "net/url" + "strings" + + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + + "github.com/bytedance/gopkg/cache/asynccache" + "github.com/golang-jwt/jwt/v5" + + "fmt" + "time" +) + +type Credentials struct { + ProjectID string `json:"project_id"` + PrivateKeyID string `json:"private_key_id"` + PrivateKey string `json:"private_key"` + ClientEmail string `json:"client_email"` + ClientID string `json:"client_id"` +} + +var Cache = asynccache.NewAsyncCache(asynccache.Options{ + RefreshDuration: time.Minute * 35, + EnableExpire: true, + ExpireDuration: time.Minute * 30, + Fetcher: func(key string) (interface{}, error) { + return nil, errors.New("not found") + }, +}) + +func getAccessToken(a *Adaptor, info *relaycommon.RelayInfo) (string, error) { + var cacheKey string + if info.ChannelIsMultiKey { + cacheKey = fmt.Sprintf("access-token-%d-%d", info.ChannelId, info.ChannelMultiKeyIndex) + } else { + cacheKey = fmt.Sprintf("access-token-%d", info.ChannelId) + } + val, err := Cache.Get(cacheKey) + if err == nil { + return val.(string), nil + } + + signedJWT, err := createSignedJWT(a.AccountCredentials.ClientEmail, a.AccountCredentials.PrivateKey) + if err != nil { + return "", fmt.Errorf("failed to create signed JWT: %w", err) + } + newToken, err := exchangeJwtForAccessToken(signedJWT, info) + if err != nil { + return "", fmt.Errorf("failed to exchange JWT for access token: %w", err) + } + if err := Cache.SetDefault(cacheKey, newToken); err { + return newToken, nil + } + return newToken, nil +} + +func createSignedJWT(email, privateKeyPEM string) (string, error) { + + privateKeyPEM = strings.ReplaceAll(privateKeyPEM, "-----BEGIN PRIVATE KEY-----", "") + privateKeyPEM = strings.ReplaceAll(privateKeyPEM, "-----END PRIVATE KEY-----", "") + privateKeyPEM = strings.ReplaceAll(privateKeyPEM, "\r", "") + privateKeyPEM = strings.ReplaceAll(privateKeyPEM, "\n", "") + privateKeyPEM = strings.ReplaceAll(privateKeyPEM, "\\n", "") + + block, _ := pem.Decode([]byte("-----BEGIN PRIVATE KEY-----\n" + privateKeyPEM + "\n-----END PRIVATE KEY-----")) + if block == nil { + return "", fmt.Errorf("failed to parse PEM block containing the private key") + } + + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return "", err + } + + rsaPrivateKey, ok := privateKey.(*rsa.PrivateKey) + if !ok { + return "", fmt.Errorf("not an RSA private key") + } + + now := time.Now() + claims := jwt.MapClaims{ + "iss": email, + "scope": "https://www.googleapis.com/auth/cloud-platform", + "aud": "https://www.googleapis.com/oauth2/v4/token", + "exp": now.Add(time.Minute * 35).Unix(), + "iat": now.Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signedToken, err := token.SignedString(rsaPrivateKey) + if err != nil { + return "", err + } + + return signedToken, nil +} + +func exchangeJwtForAccessToken(signedJWT string, info *relaycommon.RelayInfo) (string, error) { + + authURL := "https://www.googleapis.com/oauth2/v4/token" + data := url.Values{} + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + data.Set("assertion", signedJWT) + + var client *http.Client + var err error + if info.ChannelSetting.Proxy != "" { + client, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy) + if err != nil { + return "", fmt.Errorf("new proxy http client failed: %w", err) + } + } else { + client = service.GetHttpClient() + } + + resp, err := client.PostForm(authURL, data) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if accessToken, ok := result["access_token"].(string); ok { + return accessToken, nil + } + + return "", fmt.Errorf("failed to get access token: %v", result) +} + +func AcquireAccessToken(creds Credentials, proxy string) (string, error) { + signedJWT, err := createSignedJWT(creds.ClientEmail, creds.PrivateKey) + if err != nil { + return "", fmt.Errorf("failed to create signed JWT: %w", err) + } + return exchangeJwtForAccessTokenWithProxy(signedJWT, proxy) +} + +func exchangeJwtForAccessTokenWithProxy(signedJWT string, proxy string) (string, error) { + authURL := "https://www.googleapis.com/oauth2/v4/token" + data := url.Values{} + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + data.Set("assertion", signedJWT) + + var client *http.Client + var err error + if proxy != "" { + client, err = service.NewProxyHttpClient(proxy) + if err != nil { + return "", fmt.Errorf("new proxy http client failed: %w", err) + } + } else { + client = service.GetHttpClient() + } + + resp, err := client.PostForm(authURL, data) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if accessToken, ok := result["access_token"].(string); ok { + return accessToken, nil + } + return "", fmt.Errorf("failed to get access token: %v", result) +} diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..74cad439bb9cdf1302ba555144563469cf826076 --- /dev/null +++ b/relay/channel/volcengine/adaptor.go @@ -0,0 +1,401 @@ +package volcengine + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + + channelconstant "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/claude" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +const ( + contextKeyTTSRequest = "volcengine_tts_request" + contextKeyResponseFormat = "response_format" + DoubaoCodingPlan = "doubao-coding-plan" + DoubaoCodingPlanClaudeBaseURL = "https://ark.cn-beijing.volces.com/api/coding" + DoubaoCodingPlanOpenAIBaseURL = "https://ark.cn-beijing.volces.com/api/coding/v3" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + if info.ChannelBaseUrl == DoubaoCodingPlan { + adaptor := claude.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) + } + adaptor := openai.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + if info.RelayMode != constant.RelayModeAudioSpeech { + return nil, errors.New("unsupported audio relay mode") + } + + appID, token, err := parseVolcengineAuth(info.ApiKey) + if err != nil { + return nil, err + } + + voiceType := mapVoiceType(request.Voice) + speedRatio := request.Speed + encoding := mapEncoding(request.ResponseFormat) + + c.Set(contextKeyResponseFormat, encoding) + + volcRequest := VolcengineTTSRequest{ + App: VolcengineTTSApp{ + AppID: appID, + Token: token, + Cluster: "volcano_tts", + }, + User: VolcengineTTSUser{ + UID: "openai_relay_user", + }, + Audio: VolcengineTTSAudio{ + VoiceType: voiceType, + Encoding: encoding, + SpeedRatio: speedRatio, + Rate: 24000, + }, + Request: VolcengineTTSReqInfo{ + ReqID: generateRequestID(), + Text: request.Input, + Operation: "submit", + Model: info.OriginModelName, + }, + } + + if len(request.Metadata) > 0 { + if err = json.Unmarshal(request.Metadata, &volcRequest); err != nil { + return nil, fmt.Errorf("error unmarshalling metadata to volcengine request: %w", err) + } + } + + c.Set(contextKeyTTSRequest, volcRequest) + + if volcRequest.Request.Operation == "submit" { + info.IsStream = true + } + + jsonData, err := json.Marshal(volcRequest) + if err != nil { + return nil, fmt.Errorf("error marshalling volcengine request: %w", err) + } + + return bytes.NewReader(jsonData), nil +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + switch info.RelayMode { + case constant.RelayModeImagesGenerations: + return request, nil + // 根据官方文档,并没有发现豆包生图支持表单请求:https://www.volcengine.com/docs/82379/1824121 + //case constant.RelayModeImagesEdits: + // + // var requestBody bytes.Buffer + // writer := multipart.NewWriter(&requestBody) + // + // writer.WriteField("model", request.Model) + // + // formData := c.Request.PostForm + // for key, values := range formData { + // if key == "model" { + // continue + // } + // for _, value := range values { + // writer.WriteField(key, value) + // } + // } + // + // if err := c.Request.ParseMultipartForm(32 << 20); err != nil { + // return nil, errors.New("failed to parse multipart form") + // } + // + // if c.Request.MultipartForm != nil && c.Request.MultipartForm.File != nil { + // var imageFiles []*multipart.FileHeader + // var exists bool + // + // if imageFiles, exists = c.Request.MultipartForm.File["image"]; !exists || len(imageFiles) == 0 { + // if imageFiles, exists = c.Request.MultipartForm.File["image[]"]; !exists || len(imageFiles) == 0 { + // foundArrayImages := false + // for fieldName, files := range c.Request.MultipartForm.File { + // if strings.HasPrefix(fieldName, "image[") && len(files) > 0 { + // foundArrayImages = true + // for _, file := range files { + // imageFiles = append(imageFiles, file) + // } + // } + // } + // + // if !foundArrayImages && (len(imageFiles) == 0) { + // return nil, errors.New("image is required") + // } + // } + // } + // + // for i, fileHeader := range imageFiles { + // file, err := fileHeader.Open() + // if err != nil { + // return nil, fmt.Errorf("failed to open image file %d: %w", i, err) + // } + // defer file.Close() + // + // fieldName := "image" + // if len(imageFiles) > 1 { + // fieldName = "image[]" + // } + // + // mimeType := detectImageMimeType(fileHeader.Filename) + // + // h := make(textproto.MIMEHeader) + // h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileHeader.Filename)) + // h.Set("Content-Type", mimeType) + // + // part, err := writer.CreatePart(h) + // if err != nil { + // return nil, fmt.Errorf("create form part failed for image %d: %w", i, err) + // } + // + // if _, err := io.Copy(part, file); err != nil { + // return nil, fmt.Errorf("copy file failed for image %d: %w", i, err) + // } + // } + // + // if maskFiles, exists := c.Request.MultipartForm.File["mask"]; exists && len(maskFiles) > 0 { + // maskFile, err := maskFiles[0].Open() + // if err != nil { + // return nil, errors.New("failed to open mask file") + // } + // defer maskFile.Close() + // + // mimeType := detectImageMimeType(maskFiles[0].Filename) + // + // h := make(textproto.MIMEHeader) + // h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="mask"; filename="%s"`, maskFiles[0].Filename)) + // h.Set("Content-Type", mimeType) + // + // maskPart, err := writer.CreatePart(h) + // if err != nil { + // return nil, errors.New("create form file failed for mask") + // } + // + // if _, err := io.Copy(maskPart, maskFile); err != nil { + // return nil, errors.New("copy mask file failed") + // } + // } + // } else { + // return nil, errors.New("no multipart form data found") + // } + // + // writer.Close() + // c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + // return bytes.NewReader(requestBody.Bytes()), nil + + default: + return request, nil + } +} + +func detectImageMimeType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".webp": + return "image/webp" + default: + if strings.HasPrefix(ext, ".jp") { + return "image/jpeg" + } + return "image/png" + } +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + baseUrl := info.ChannelBaseUrl + if baseUrl == "" { + baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] + } + + switch info.RelayFormat { + case types.RelayFormatClaude: + if baseUrl == DoubaoCodingPlan { + return fmt.Sprintf("%s/v1/messages", DoubaoCodingPlanClaudeBaseURL), nil + } + if strings.HasPrefix(info.UpstreamModelName, "bot") { + return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil + } + return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil + default: + switch info.RelayMode { + case constant.RelayModeChatCompletions: + if baseUrl == DoubaoCodingPlan { + return fmt.Sprintf("%s/chat/completions", DoubaoCodingPlanOpenAIBaseURL), nil + } + if strings.HasPrefix(info.UpstreamModelName, "bot") { + return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil + } + return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil + case constant.RelayModeEmbeddings: + return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil + //豆包的图生图也走generations接口: https://www.volcengine.com/docs/82379/1824121 + case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits: + return fmt.Sprintf("%s/api/v3/images/generations", baseUrl), nil + //case constant.RelayModeImagesEdits: + // return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil + case constant.RelayModeRerank: + return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil + case constant.RelayModeAudioSpeech: + if baseUrl == channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] { + return "wss://openspeech.bytedance.com/api/v1/tts/ws_binary", nil + } + return fmt.Sprintf("%s/v1/audio/speech", baseUrl), nil + default: + } + } + return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode) +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + + if info.RelayMode == constant.RelayModeAudioSpeech { + parts := strings.Split(info.ApiKey, "|") + if len(parts) == 2 { + req.Set("Authorization", "Bearer;"+parts[1]) + } + req.Set("Content-Type", "application/json") + return nil + } else if info.RelayMode == constant.RelayModeImagesEdits { + req.Set("Content-Type", gin.MIMEJSON) + } + + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) && + strings.HasSuffix(info.UpstreamModelName, "-thinking") && + strings.HasPrefix(info.UpstreamModelName, "deepseek") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") + request.Model = info.UpstreamModelName + request.THINKING = json.RawMessage(`{"type": "enabled"}`) + } + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + if info.RelayMode == constant.RelayModeAudioSpeech { + baseUrl := info.ChannelBaseUrl + if baseUrl == "" { + baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] + } + + if baseUrl == channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] { + if info.IsStream { + return nil, nil + } + } + } + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.RelayFormat == types.RelayFormatClaude && info.ChannelBaseUrl == DoubaoCodingPlan { + if info.IsStream { + return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + } + return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) + } + + if info.RelayMode == constant.RelayModeAudioSpeech { + encoding := mapEncoding(c.GetString(contextKeyResponseFormat)) + if info.IsStream { + volcRequestInterface, exists := c.Get(contextKeyTTSRequest) + if !exists { + return nil, types.NewErrorWithStatusCode( + errors.New("volcengine TTS request not found in context"), + types.ErrorCodeBadRequestBody, + http.StatusInternalServerError, + ) + } + + volcRequest, ok := volcRequestInterface.(VolcengineTTSRequest) + if !ok { + return nil, types.NewErrorWithStatusCode( + errors.New("invalid volcengine TTS request type"), + types.ErrorCodeBadRequestBody, + http.StatusInternalServerError, + ) + } + + // Get the WebSocket URL + requestURL, urlErr := a.GetRequestURL(info) + if urlErr != nil { + return nil, types.NewErrorWithStatusCode( + urlErr, + types.ErrorCodeBadRequestBody, + http.StatusInternalServerError, + ) + } + return handleTTSWebSocketResponse(c, requestURL, volcRequest, info, encoding) + } + return handleTTSResponse(c, resp, info, encoding) + } + + adaptor := openai.Adaptor{} + usage, err = adaptor.DoResponse(c, resp, info) + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/volcengine/constants.go b/relay/channel/volcengine/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..87a12b27c9d3909d20a18bb5311ad39e44d31572 --- /dev/null +++ b/relay/channel/volcengine/constants.go @@ -0,0 +1,19 @@ +package volcengine + +var ModelList = []string{ + "Doubao-pro-128k", + "Doubao-pro-32k", + "Doubao-pro-4k", + "Doubao-lite-128k", + "Doubao-lite-32k", + "Doubao-lite-4k", + "Doubao-embedding", + "doubao-seedream-4-0-250828", + "seedream-4-0-250828", + "doubao-seedance-1-0-pro-250528", + "seedance-1-0-pro-250528", + "doubao-seed-1-6-thinking-250715", + "seed-1-6-thinking-250715", +} + +var ChannelName = "volcengine" diff --git a/relay/channel/volcengine/protocols.go b/relay/channel/volcengine/protocols.go new file mode 100644 index 0000000000000000000000000000000000000000..fb7dcd578cea13318b3d50bafcbf7ab9c83e23d5 --- /dev/null +++ b/relay/channel/volcengine/protocols.go @@ -0,0 +1,533 @@ +package volcengine + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "math" + + "github.com/gorilla/websocket" +) + +type ( + EventType int32 + MsgType uint8 + MsgTypeFlagBits uint8 + VersionBits uint8 + HeaderSizeBits uint8 + SerializationBits uint8 + CompressionBits uint8 +) + +const ( + MsgTypeFlagNoSeq MsgTypeFlagBits = 0 + MsgTypeFlagPositiveSeq MsgTypeFlagBits = 0b1 + MsgTypeFlagNegativeSeq MsgTypeFlagBits = 0b11 + MsgTypeFlagWithEvent MsgTypeFlagBits = 0b100 +) + +const ( + Version1 VersionBits = iota + 1 +) + +const ( + HeaderSize4 HeaderSizeBits = iota + 1 +) + +const ( + SerializationJSON SerializationBits = 0b1 +) + +const ( + CompressionNone CompressionBits = 0 +) + +const ( + MsgTypeFullClientRequest MsgType = 0b1 + MsgTypeAudioOnlyClient MsgType = 0b10 + MsgTypeFullServerResponse MsgType = 0b1001 + MsgTypeAudioOnlyServer MsgType = 0b1011 + MsgTypeFrontEndResultServer MsgType = 0b1100 + MsgTypeError MsgType = 0b1111 +) + +func (t MsgType) String() string { + switch t { + case MsgTypeFullClientRequest: + return "MsgType_FullClientRequest" + case MsgTypeAudioOnlyClient: + return "MsgType_AudioOnlyClient" + case MsgTypeFullServerResponse: + return "MsgType_FullServerResponse" + case MsgTypeAudioOnlyServer: + return "MsgType_AudioOnlyServer" + case MsgTypeError: + return "MsgType_Error" + case MsgTypeFrontEndResultServer: + return "MsgType_FrontEndResultServer" + default: + return fmt.Sprintf("MsgType_(%d)", t) + } +} + +const ( + EventType_None EventType = 0 + + EventType_StartConnection EventType = 1 + EventType_FinishConnection EventType = 2 + + EventType_ConnectionStarted EventType = 50 + EventType_ConnectionFailed EventType = 51 + EventType_ConnectionFinished EventType = 52 + + EventType_StartSession EventType = 100 + EventType_CancelSession EventType = 101 + EventType_FinishSession EventType = 102 + + EventType_SessionStarted EventType = 150 + EventType_SessionCanceled EventType = 151 + EventType_SessionFinished EventType = 152 + EventType_SessionFailed EventType = 153 + + EventType_UsageResponse EventType = 154 + + EventType_TaskRequest EventType = 200 + EventType_UpdateConfig EventType = 201 + + EventType_AudioMuted EventType = 250 + + EventType_SayHello EventType = 300 + + EventType_TTSSentenceStart EventType = 350 + EventType_TTSSentenceEnd EventType = 351 + EventType_TTSResponse EventType = 352 + EventType_TTSEnded EventType = 359 + EventType_PodcastRoundStart EventType = 360 + EventType_PodcastRoundResponse EventType = 361 + EventType_PodcastRoundEnd EventType = 362 + + EventType_ASRInfo EventType = 450 + EventType_ASRResponse EventType = 451 + EventType_ASREnded EventType = 459 + + EventType_ChatTTSText EventType = 500 + + EventType_ChatResponse EventType = 550 + EventType_ChatEnded EventType = 559 + + EventType_SourceSubtitleStart EventType = 650 + EventType_SourceSubtitleResponse EventType = 651 + EventType_SourceSubtitleEnd EventType = 652 + + EventType_TranslationSubtitleStart EventType = 653 + EventType_TranslationSubtitleResponse EventType = 654 + EventType_TranslationSubtitleEnd EventType = 655 +) + +func (t EventType) String() string { + switch t { + case EventType_None: + return "EventType_None" + case EventType_StartConnection: + return "EventType_StartConnection" + case EventType_FinishConnection: + return "EventType_FinishConnection" + case EventType_ConnectionStarted: + return "EventType_ConnectionStarted" + case EventType_ConnectionFailed: + return "EventType_ConnectionFailed" + case EventType_ConnectionFinished: + return "EventType_ConnectionFinished" + case EventType_StartSession: + return "EventType_StartSession" + case EventType_CancelSession: + return "EventType_CancelSession" + case EventType_FinishSession: + return "EventType_FinishSession" + case EventType_SessionStarted: + return "EventType_SessionStarted" + case EventType_SessionCanceled: + return "EventType_SessionCanceled" + case EventType_SessionFinished: + return "EventType_SessionFinished" + case EventType_SessionFailed: + return "EventType_SessionFailed" + case EventType_UsageResponse: + return "EventType_UsageResponse" + case EventType_TaskRequest: + return "EventType_TaskRequest" + case EventType_UpdateConfig: + return "EventType_UpdateConfig" + case EventType_AudioMuted: + return "EventType_AudioMuted" + case EventType_SayHello: + return "EventType_SayHello" + case EventType_TTSSentenceStart: + return "EventType_TTSSentenceStart" + case EventType_TTSSentenceEnd: + return "EventType_TTSSentenceEnd" + case EventType_TTSResponse: + return "EventType_TTSResponse" + case EventType_TTSEnded: + return "EventType_TTSEnded" + case EventType_PodcastRoundStart: + return "EventType_PodcastRoundStart" + case EventType_PodcastRoundResponse: + return "EventType_PodcastRoundResponse" + case EventType_PodcastRoundEnd: + return "EventType_PodcastRoundEnd" + case EventType_ASRInfo: + return "EventType_ASRInfo" + case EventType_ASRResponse: + return "EventType_ASRResponse" + case EventType_ASREnded: + return "EventType_ASREnded" + case EventType_ChatTTSText: + return "EventType_ChatTTSText" + case EventType_ChatResponse: + return "EventType_ChatResponse" + case EventType_ChatEnded: + return "EventType_ChatEnded" + case EventType_SourceSubtitleStart: + return "EventType_SourceSubtitleStart" + case EventType_SourceSubtitleResponse: + return "EventType_SourceSubtitleResponse" + case EventType_SourceSubtitleEnd: + return "EventType_SourceSubtitleEnd" + case EventType_TranslationSubtitleStart: + return "EventType_TranslationSubtitleStart" + case EventType_TranslationSubtitleResponse: + return "EventType_TranslationSubtitleResponse" + case EventType_TranslationSubtitleEnd: + return "EventType_TranslationSubtitleEnd" + default: + return fmt.Sprintf("EventType_(%d)", t) + } +} + +type Message struct { + Version VersionBits + HeaderSize HeaderSizeBits + MsgType MsgType + MsgTypeFlag MsgTypeFlagBits + Serialization SerializationBits + Compression CompressionBits + + EventType EventType + SessionID string + ConnectID string + Sequence int32 + ErrorCode uint32 + + Payload []byte +} + +func NewMessageFromBytes(data []byte) (*Message, error) { + if len(data) < 3 { + return nil, fmt.Errorf("data too short: expected at least 3 bytes, got %d", len(data)) + } + + typeAndFlag := data[1] + + msg, err := NewMessage(MsgType(typeAndFlag>>4), MsgTypeFlagBits(typeAndFlag&0b00001111)) + if err != nil { + return nil, err + } + + if err := msg.Unmarshal(data); err != nil { + return nil, err + } + + return msg, nil +} + +func NewMessage(msgType MsgType, flag MsgTypeFlagBits) (*Message, error) { + return &Message{ + MsgType: msgType, + MsgTypeFlag: flag, + Version: Version1, + HeaderSize: HeaderSize4, + Serialization: SerializationJSON, + Compression: CompressionNone, + }, nil +} + +func (m *Message) String() string { + switch m.MsgType { + case MsgTypeAudioOnlyServer, MsgTypeAudioOnlyClient: + if m.MsgTypeFlag == MsgTypeFlagPositiveSeq || m.MsgTypeFlag == MsgTypeFlagNegativeSeq { + return fmt.Sprintf("%s, %s, Sequence: %d, PayloadSize: %d", m.MsgType, m.EventType, m.Sequence, len(m.Payload)) + } + return fmt.Sprintf("%s, %s, PayloadSize: %d", m.MsgType, m.EventType, len(m.Payload)) + case MsgTypeError: + return fmt.Sprintf("%s, %s, ErrorCode: %d, Payload: %s", m.MsgType, m.EventType, m.ErrorCode, string(m.Payload)) + default: + if m.MsgTypeFlag == MsgTypeFlagPositiveSeq || m.MsgTypeFlag == MsgTypeFlagNegativeSeq { + return fmt.Sprintf("%s, %s, Sequence: %d, Payload: %s", + m.MsgType, m.EventType, m.Sequence, string(m.Payload)) + } + return fmt.Sprintf("%s, %s, Payload: %s", m.MsgType, m.EventType, string(m.Payload)) + } +} + +func (m *Message) Marshal() ([]byte, error) { + buf := new(bytes.Buffer) + + header := []uint8{ + uint8(m.Version)<<4 | uint8(m.HeaderSize), + uint8(m.MsgType)<<4 | uint8(m.MsgTypeFlag), + uint8(m.Serialization)<<4 | uint8(m.Compression), + } + + headerSize := 4 * int(m.HeaderSize) + if padding := headerSize - len(header); padding > 0 { + header = append(header, make([]uint8, padding)...) + } + + if err := binary.Write(buf, binary.BigEndian, header); err != nil { + return nil, err + } + + writers, err := m.writers() + if err != nil { + return nil, err + } + + for _, write := range writers { + if err := write(buf); err != nil { + return nil, err + } + } + + return buf.Bytes(), nil +} + +func (m *Message) Unmarshal(data []byte) error { + buf := bytes.NewBuffer(data) + + versionAndHeaderSize, err := buf.ReadByte() + if err != nil { + return err + } + + m.Version = VersionBits(versionAndHeaderSize >> 4) + m.HeaderSize = HeaderSizeBits(versionAndHeaderSize & 0b00001111) + + _, err = buf.ReadByte() + if err != nil { + return err + } + + serializationCompression, err := buf.ReadByte() + if err != nil { + return err + } + + m.Serialization = SerializationBits(serializationCompression & 0b11110000) + m.Compression = CompressionBits(serializationCompression & 0b00001111) + + headerSize := 4 * int(m.HeaderSize) + readSize := 3 + if paddingSize := headerSize - readSize; paddingSize > 0 { + if n, err := buf.Read(make([]byte, paddingSize)); err != nil || n < paddingSize { + return fmt.Errorf("insufficient header bytes: expected %d, got %d", paddingSize, n) + } + } + + readers, err := m.readers() + if err != nil { + return err + } + + for _, read := range readers { + if err := read(buf); err != nil { + return err + } + } + + if _, err := buf.ReadByte(); err != io.EOF { + return fmt.Errorf("unexpected data after message: %v", err) + } + + return nil +} + +func (m *Message) writers() (writers []func(*bytes.Buffer) error, _ error) { + if m.MsgTypeFlag == MsgTypeFlagWithEvent { + writers = append(writers, m.writeEvent, m.writeSessionID) + } + + switch m.MsgType { + case MsgTypeFullClientRequest, MsgTypeFullServerResponse, MsgTypeFrontEndResultServer, MsgTypeAudioOnlyClient, MsgTypeAudioOnlyServer: + if m.MsgTypeFlag == MsgTypeFlagPositiveSeq || m.MsgTypeFlag == MsgTypeFlagNegativeSeq { + writers = append(writers, m.writeSequence) + } + case MsgTypeError: + writers = append(writers, m.writeErrorCode) + default: + return nil, fmt.Errorf("unsupported message type: %d", m.MsgType) + } + + writers = append(writers, m.writePayload) + return writers, nil +} + +func (m *Message) writeEvent(buf *bytes.Buffer) error { + return binary.Write(buf, binary.BigEndian, m.EventType) +} + +func (m *Message) writeSessionID(buf *bytes.Buffer) error { + switch m.EventType { + case EventType_StartConnection, EventType_FinishConnection, + EventType_ConnectionStarted, EventType_ConnectionFailed: + return nil + } + + size := len(m.SessionID) + if int64(size) > math.MaxUint32 { + return fmt.Errorf("session ID size (%d) exceeds max(uint32)", size) + } + + if err := binary.Write(buf, binary.BigEndian, uint32(size)); err != nil { + return err + } + + buf.WriteString(m.SessionID) + return nil +} + +func (m *Message) writeSequence(buf *bytes.Buffer) error { + return binary.Write(buf, binary.BigEndian, m.Sequence) +} + +func (m *Message) writeErrorCode(buf *bytes.Buffer) error { + return binary.Write(buf, binary.BigEndian, m.ErrorCode) +} + +func (m *Message) writePayload(buf *bytes.Buffer) error { + size := len(m.Payload) + if int64(size) > math.MaxUint32 { + return fmt.Errorf("payload size (%d) exceeds max(uint32)", size) + } + + if err := binary.Write(buf, binary.BigEndian, uint32(size)); err != nil { + return err + } + + buf.Write(m.Payload) + return nil +} + +func (m *Message) readers() (readers []func(*bytes.Buffer) error, _ error) { + switch m.MsgType { + case MsgTypeFullClientRequest, MsgTypeFullServerResponse, MsgTypeFrontEndResultServer, MsgTypeAudioOnlyClient, MsgTypeAudioOnlyServer: + if m.MsgTypeFlag == MsgTypeFlagPositiveSeq || m.MsgTypeFlag == MsgTypeFlagNegativeSeq { + readers = append(readers, m.readSequence) + } + case MsgTypeError: + readers = append(readers, m.readErrorCode) + default: + return nil, fmt.Errorf("unsupported message type: %d", m.MsgType) + } + + if m.MsgTypeFlag == MsgTypeFlagWithEvent { + readers = append(readers, m.readEvent, m.readSessionID, m.readConnectID) + } + + readers = append(readers, m.readPayload) + return readers, nil +} + +func (m *Message) readEvent(buf *bytes.Buffer) error { + return binary.Read(buf, binary.BigEndian, &m.EventType) +} + +func (m *Message) readSessionID(buf *bytes.Buffer) error { + switch m.EventType { + case EventType_StartConnection, EventType_FinishConnection, + EventType_ConnectionStarted, EventType_ConnectionFailed, + EventType_ConnectionFinished: + return nil + } + + var size uint32 + if err := binary.Read(buf, binary.BigEndian, &size); err != nil { + return err + } + + if size > 0 { + m.SessionID = string(buf.Next(int(size))) + } + + return nil +} + +func (m *Message) readConnectID(buf *bytes.Buffer) error { + switch m.EventType { + case EventType_ConnectionStarted, EventType_ConnectionFailed, + EventType_ConnectionFinished: + default: + return nil + } + + var size uint32 + if err := binary.Read(buf, binary.BigEndian, &size); err != nil { + return err + } + + if size > 0 { + m.ConnectID = string(buf.Next(int(size))) + } + + return nil +} + +func (m *Message) readSequence(buf *bytes.Buffer) error { + return binary.Read(buf, binary.BigEndian, &m.Sequence) +} + +func (m *Message) readErrorCode(buf *bytes.Buffer) error { + return binary.Read(buf, binary.BigEndian, &m.ErrorCode) +} + +func (m *Message) readPayload(buf *bytes.Buffer) error { + var size uint32 + if err := binary.Read(buf, binary.BigEndian, &size); err != nil { + return err + } + + if size > 0 { + m.Payload = buf.Next(int(size)) + } + + return nil +} + +func ReceiveMessage(conn *websocket.Conn) (*Message, error) { + mt, frame, err := conn.ReadMessage() + if err != nil { + return nil, err + } + if mt != websocket.BinaryMessage && mt != websocket.TextMessage { + return nil, fmt.Errorf("unexpected Websocket message type: %d", mt) + } + msg, err := NewMessageFromBytes(frame) + if err != nil { + return nil, err + } + return msg, nil +} + +func FullClientRequest(conn *websocket.Conn, payload []byte) error { + msg, err := NewMessage(MsgTypeFullClientRequest, MsgTypeFlagNoSeq) + if err != nil { + return err + } + msg.Payload = payload + frame, err := msg.Marshal() + if err != nil { + return err + } + return conn.WriteMessage(websocket.BinaryMessage, frame) +} diff --git a/relay/channel/volcengine/tts.go b/relay/channel/volcengine/tts.go new file mode 100644 index 0000000000000000000000000000000000000000..166fab8efaf593a61aab9ecad2c173b7f2a11e58 --- /dev/null +++ b/relay/channel/volcengine/tts.go @@ -0,0 +1,305 @@ +package volcengine + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +type VolcengineTTSRequest struct { + App VolcengineTTSApp `json:"app"` + User VolcengineTTSUser `json:"user"` + Audio VolcengineTTSAudio `json:"audio"` + Request VolcengineTTSReqInfo `json:"request"` +} + +type VolcengineTTSApp struct { + AppID string `json:"appid"` + Token string `json:"token"` + Cluster string `json:"cluster"` +} + +type VolcengineTTSUser struct { + UID string `json:"uid"` +} + +type VolcengineTTSAudio struct { + VoiceType string `json:"voice_type"` + Encoding string `json:"encoding"` + SpeedRatio float64 `json:"speed_ratio"` + Rate int `json:"rate"` + Bitrate int `json:"bitrate,omitempty"` + LoudnessRatio float64 `json:"loudness_ratio,omitempty"` + EnableEmotion bool `json:"enable_emotion,omitempty"` + Emotion string `json:"emotion,omitempty"` + EmotionScale float64 `json:"emotion_scale,omitempty"` + ExplicitLanguage string `json:"explicit_language,omitempty"` + ContextLanguage string `json:"context_language,omitempty"` +} + +type VolcengineTTSReqInfo struct { + ReqID string `json:"reqid"` + Text string `json:"text"` + Operation string `json:"operation"` + Model string `json:"model,omitempty"` + TextType string `json:"text_type,omitempty"` + SilenceDuration float64 `json:"silence_duration,omitempty"` + WithTimestamp interface{} `json:"with_timestamp,omitempty"` + ExtraParam *VolcengineTTSExtraParam `json:"extra_param,omitempty"` +} + +type VolcengineTTSExtraParam struct { + DisableMarkdownFilter bool `json:"disable_markdown_filter,omitempty"` + EnableLatexTn bool `json:"enable_latex_tn,omitempty"` + MuteCutThreshold string `json:"mute_cut_threshold,omitempty"` + MuteCutRemainMs string `json:"mute_cut_remain_ms,omitempty"` + DisableEmojiFilter bool `json:"disable_emoji_filter,omitempty"` + UnsupportedCharRatioThresh float64 `json:"unsupported_char_ratio_thresh,omitempty"` + AigcWatermark bool `json:"aigc_watermark,omitempty"` + CacheConfig *VolcengineTTSCacheConfig `json:"cache_config,omitempty"` +} + +type VolcengineTTSCacheConfig struct { + TextType int `json:"text_type,omitempty"` + UseCache bool `json:"use_cache,omitempty"` +} + +type VolcengineTTSResponse struct { + ReqID string `json:"reqid"` + Code int `json:"code"` + Message string `json:"message"` + Sequence int `json:"sequence"` + Data string `json:"data"` + Addition *VolcengineTTSAdditionInfo `json:"addition,omitempty"` +} + +type VolcengineTTSAdditionInfo struct { + Duration string `json:"duration"` +} + +var openAIToVolcengineVoiceMap = map[string]string{ + "alloy": "zh_male_M392_conversation_wvae_bigtts", + "echo": "zh_male_wenhao_mars_bigtts", + "fable": "zh_female_tianmei_mars_bigtts", + "onyx": "zh_male_zhibei_mars_bigtts", + "nova": "zh_female_shuangkuaisisi_mars_bigtts", + "shimmer": "zh_female_cancan_mars_bigtts", +} + +var responseFormatToEncodingMap = map[string]string{ + "mp3": "mp3", + "opus": "ogg_opus", + "aac": "mp3", + "flac": "mp3", + "wav": "wav", + "pcm": "pcm", +} + +func parseVolcengineAuth(apiKey string) (appID, token string, err error) { + parts := strings.Split(apiKey, "|") + if len(parts) != 2 { + return "", "", errors.New("invalid api key format, expected: appid|access_token") + } + return parts[0], parts[1], nil +} + +func mapVoiceType(openAIVoice string) string { + if voice, ok := openAIToVolcengineVoiceMap[openAIVoice]; ok { + return voice + } + return openAIVoice +} + +func mapEncoding(responseFormat string) string { + if encoding, ok := responseFormatToEncodingMap[responseFormat]; ok { + return encoding + } + return "mp3" +} + +func getContentTypeByEncoding(encoding string) string { + contentTypeMap := map[string]string{ + "mp3": "audio/mpeg", + "ogg_opus": "audio/ogg", + "wav": "audio/wav", + "pcm": "audio/pcm", + } + if ct, ok := contentTypeMap[encoding]; ok { + return ct + } + return "application/octet-stream" +} + +func handleTTSResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, encoding string) (usage any, err *types.NewAPIError) { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, types.NewErrorWithStatusCode( + errors.New("failed to read volcengine response"), + types.ErrorCodeReadResponseBodyFailed, + http.StatusInternalServerError, + ) + } + defer resp.Body.Close() + + var volcResp VolcengineTTSResponse + if unmarshalErr := json.Unmarshal(body, &volcResp); unmarshalErr != nil { + return nil, types.NewErrorWithStatusCode( + errors.New("failed to parse volcengine response"), + types.ErrorCodeBadResponseBody, + http.StatusInternalServerError, + ) + } + + if volcResp.Code != 3000 { + return nil, types.NewErrorWithStatusCode( + errors.New(volcResp.Message), + types.ErrorCodeBadResponse, + http.StatusBadRequest, + ) + } + + audioData, decodeErr := base64.StdEncoding.DecodeString(volcResp.Data) + if decodeErr != nil { + return nil, types.NewErrorWithStatusCode( + errors.New("failed to decode audio data"), + types.ErrorCodeBadResponseBody, + http.StatusInternalServerError, + ) + } + + contentType := getContentTypeByEncoding(encoding) + c.Header("Content-Type", contentType) + c.Data(http.StatusOK, contentType, audioData) + + usage = &dto.Usage{ + PromptTokens: info.PromptTokens, + CompletionTokens: 0, + TotalTokens: info.PromptTokens, + } + + return usage, nil +} + +func generateRequestID() string { + return uuid.New().String() +} + +func handleTTSWebSocketResponse(c *gin.Context, requestURL string, volcRequest VolcengineTTSRequest, info *relaycommon.RelayInfo, encoding string) (usage any, err *types.NewAPIError) { + _, token, parseErr := parseVolcengineAuth(info.ApiKey) + if parseErr != nil { + return nil, types.NewErrorWithStatusCode( + parseErr, + types.ErrorCodeChannelInvalidKey, + http.StatusUnauthorized, + ) + } + + header := http.Header{} + header.Set("Authorization", fmt.Sprintf("Bearer;%s", token)) + + conn, resp, dialErr := websocket.DefaultDialer.DialContext(context.Background(), requestURL, header) + if dialErr != nil { + if resp != nil { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("failed to connect to websocket: %w, status: %d", dialErr, resp.StatusCode), + types.ErrorCodeBadResponseStatusCode, + http.StatusBadGateway, + ) + } + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("failed to connect to websocket: %w", dialErr), + types.ErrorCodeBadResponseStatusCode, + http.StatusBadGateway, + ) + } + defer conn.Close() + + payload, marshalErr := json.Marshal(volcRequest) + if marshalErr != nil { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("failed to marshal request: %w", marshalErr), + types.ErrorCodeBadRequestBody, + http.StatusInternalServerError, + ) + } + + if sendErr := FullClientRequest(conn, payload); sendErr != nil { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("failed to send request: %w", sendErr), + types.ErrorCodeBadRequestBody, + http.StatusInternalServerError, + ) + } + + contentType := getContentTypeByEncoding(encoding) + c.Header("Content-Type", contentType) + c.Header("Transfer-Encoding", "chunked") + + for { + msg, recvErr := ReceiveMessage(conn) + if recvErr != nil { + if websocket.IsCloseError(recvErr, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + break + } + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("failed to receive message: %w", recvErr), + types.ErrorCodeBadResponse, + http.StatusInternalServerError, + ) + } + + switch msg.MsgType { + case MsgTypeError: + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("received error from server: code=%d, %s", msg.ErrorCode, string(msg.Payload)), + types.ErrorCodeBadResponse, + http.StatusBadRequest, + ) + case MsgTypeFrontEndResultServer: + continue + case MsgTypeAudioOnlyServer: + if len(msg.Payload) > 0 { + if _, writeErr := c.Writer.Write(msg.Payload); writeErr != nil { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("failed to write audio data: %w", writeErr), + types.ErrorCodeBadResponse, + http.StatusInternalServerError, + ) + } + c.Writer.Flush() + } + + if msg.Sequence < 0 { + c.Status(http.StatusOK) + usage = &dto.Usage{ + PromptTokens: info.PromptTokens, + CompletionTokens: 0, + TotalTokens: info.PromptTokens, + } + return usage, nil + } + default: + continue + } + } + + c.Status(http.StatusOK) + usage = &dto.Usage{ + PromptTokens: info.PromptTokens, + CompletionTokens: 0, + TotalTokens: info.PromptTokens, + } + return usage, nil +} diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..b130723e7ef0a6bee8b314ca96a916ac56112b55 --- /dev/null +++ b/relay/channel/xai/adaptor.go @@ -0,0 +1,134 @@ +package xai + +import ( + "errors" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/QuantumNous/new-api/relay/constant" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + //panic("implement me") + return nil, errors.New("not available") +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //not available + return nil, errors.New("not available") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + xaiRequest := ImageRequest{ + Model: request.Model, + Prompt: request.Prompt, + N: int(request.N), + ResponseFormat: request.ResponseFormat, + } + return xaiRequest, nil +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if strings.HasSuffix(info.UpstreamModelName, "-search") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-search") + request.Model = info.UpstreamModelName + toMap := request.ToMap() + toMap["search_parameters"] = map[string]any{ + "mode": "on", + } + return toMap, nil + } + if strings.HasPrefix(request.Model, "grok-3-mini") { + if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 { + request.MaxCompletionTokens = request.MaxTokens + request.MaxTokens = 0 + } + if strings.HasSuffix(request.Model, "-high") { + request.ReasoningEffort = "high" + request.Model = strings.TrimSuffix(request.Model, "-high") + } else if strings.HasSuffix(request.Model, "-low") { + request.ReasoningEffort = "low" + request.Model = strings.TrimSuffix(request.Model, "-low") + } else if strings.HasSuffix(request.Model, "-medium") { + request.ReasoningEffort = "medium" + request.Model = strings.TrimSuffix(request.Model, "-medium") + } + info.ReasoningEffort = request.ReasoningEffort + info.UpstreamModelName = request.Model + } + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //not available + return nil, errors.New("not available") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayMode { + case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits: + usage, err = openai.OpenaiHandlerWithUsage(c, info, resp) + default: + if info.IsStream { + usage, err = xAIStreamHandler(c, info, resp) + } else { + usage, err = xAIHandler(c, info, resp) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/xai/constants.go b/relay/channel/xai/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..311b4bb6e290854f7b6bbf76b9886cdaa2702ae2 --- /dev/null +++ b/relay/channel/xai/constants.go @@ -0,0 +1,20 @@ +package xai + +var ModelList = []string{ + // grok-4 + "grok-4", "grok-4-0709", "grok-4-0709-search", + // grok-3 + "grok-3-beta", "grok-3-mini-beta", + // grok-3 mini + "grok-3-fast-beta", "grok-3-mini-fast-beta", + // extend grok-3-mini reasoning + "grok-3-mini-beta-high", "grok-3-mini-beta-low", "grok-3-mini-beta-medium", + "grok-3-mini-fast-beta-high", "grok-3-mini-fast-beta-low", "grok-3-mini-fast-beta-medium", + // image model + "grok-2-image", + // legacy models + "grok-2", "grok-2-vision", + "grok-beta", "grok-vision-beta", +} + +var ChannelName = "xai" diff --git a/relay/channel/xai/dto.go b/relay/channel/xai/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..371d62a43360c21723604ccb18177492e1950747 --- /dev/null +++ b/relay/channel/xai/dto.go @@ -0,0 +1,27 @@ +package xai + +import "github.com/QuantumNous/new-api/dto" + +// ChatCompletionResponse represents the response from XAI chat completion API +type ChatCompletionResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []dto.OpenAITextResponseChoice `json:"choices"` + Usage *dto.Usage `json:"usage"` + SystemFingerprint string `json:"system_fingerprint"` +} + +// quality, size or style are not supported by xAI API at the moment. +type ImageRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt" binding:"required"` + N int `json:"n,omitempty"` + // Size string `json:"size,omitempty"` + // Quality string `json:"quality,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` + // Style string `json:"style,omitempty"` + // User string `json:"user,omitempty"` + // ExtraFields json.RawMessage `json:"extra_fields,omitempty"` +} diff --git a/relay/channel/xai/text.go b/relay/channel/xai/text.go new file mode 100644 index 0000000000000000000000000000000000000000..a5acbd2a0f9ce6b0db488e0af7d37de6ccdcc0dd --- /dev/null +++ b/relay/channel/xai/text.go @@ -0,0 +1,108 @@ +package xai + +import ( + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func streamResponseXAI2OpenAI(xAIResp *dto.ChatCompletionsStreamResponse, usage *dto.Usage) *dto.ChatCompletionsStreamResponse { + if xAIResp == nil { + return nil + } + if xAIResp.Usage != nil { + xAIResp.Usage.CompletionTokens = usage.CompletionTokens + } + openAIResp := &dto.ChatCompletionsStreamResponse{ + Id: xAIResp.Id, + Object: xAIResp.Object, + Created: xAIResp.Created, + Model: xAIResp.Model, + Choices: xAIResp.Choices, + Usage: xAIResp.Usage, + } + + return openAIResp +} + +func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + usage := &dto.Usage{} + var responseTextBuilder strings.Builder + var toolCount int + var containStreamUsage bool + + helper.SetEventStreamHeaders(c) + + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + var xAIResp *dto.ChatCompletionsStreamResponse + err := json.Unmarshal([]byte(data), &xAIResp) + if err != nil { + common.SysLog("error unmarshalling stream response: " + err.Error()) + return true + } + + // 把 xAI 的usage转换为 OpenAI 的usage + if xAIResp.Usage != nil { + containStreamUsage = true + usage.PromptTokens = xAIResp.Usage.PromptTokens + usage.TotalTokens = xAIResp.Usage.TotalTokens + usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens + } + + openaiResponse := streamResponseXAI2OpenAI(xAIResp, usage) + _ = openai.ProcessStreamResponse(*openaiResponse, &responseTextBuilder, &toolCount) + err = helper.ObjectData(c, openaiResponse) + if err != nil { + common.SysLog(err.Error()) + } + return true + }) + + if !containStreamUsage { + usage = service.ResponseText2Usage(c, responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens) + usage.CompletionTokens += toolCount * 7 + } + + helper.Done(c) + service.CloseResponseBodyGracefully(resp) + return usage, nil +} + +func xAIHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer service.CloseResponseBodyGracefully(resp) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + var xaiResponse ChatCompletionResponse + err = common.Unmarshal(responseBody, &xaiResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + if xaiResponse.Usage != nil { + xaiResponse.Usage.CompletionTokens = xaiResponse.Usage.TotalTokens - xaiResponse.Usage.PromptTokens + xaiResponse.Usage.CompletionTokenDetails.TextTokens = xaiResponse.Usage.CompletionTokens - xaiResponse.Usage.CompletionTokenDetails.ReasoningTokens + } + + // new body + encodeJson, err := common.Marshal(xaiResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + + service.IOCopyBytesGracefully(c, resp, encodeJson) + + return xaiResponse.Usage, nil +} diff --git a/relay/channel/xinference/constant.go b/relay/channel/xinference/constant.go new file mode 100644 index 0000000000000000000000000000000000000000..a119084fc60554b8184bf9614c5b25956f185f90 --- /dev/null +++ b/relay/channel/xinference/constant.go @@ -0,0 +1,8 @@ +package xinference + +var ModelList = []string{ + "bge-reranker-v2-m3", + "jina-reranker-v2", +} + +var ChannelName = "xinference" diff --git a/relay/channel/xinference/dto.go b/relay/channel/xinference/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..35f339fe6e4b02556a3b8f3c4224b5e7df0b4a26 --- /dev/null +++ b/relay/channel/xinference/dto.go @@ -0,0 +1,11 @@ +package xinference + +type XinRerankResponseDocument struct { + Document any `json:"document,omitempty"` + Index int `json:"index"` + RelevanceScore float64 `json:"relevance_score"` +} + +type XinRerankResponse struct { + Results []XinRerankResponseDocument `json:"results"` +} diff --git a/relay/channel/xunfei/adaptor.go b/relay/channel/xunfei/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..686b0cbd2e12564a5d9807629f3f63785c3bc651 --- /dev/null +++ b/relay/channel/xunfei/adaptor.go @@ -0,0 +1,105 @@ +package xunfei + +import ( + "errors" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { + request *dto.GeneralOpenAIRequest +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return "", nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + a.request = request + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + // xunfei's request is not http request, so we don't need to do anything here + dummyResp := &http.Response{} + dummyResp.StatusCode = http.StatusOK + return dummyResp, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + splits := strings.Split(info.ApiKey, "|") + if len(splits) != 3 { + return nil, types.NewError(errors.New("invalid auth"), types.ErrorCodeChannelInvalidKey) + } + if a.request == nil { + return nil, types.NewError(errors.New("request is nil"), types.ErrorCodeInvalidRequest) + } + if info.IsStream { + usage, err = xunfeiStreamHandler(c, *a.request, splits[0], splits[1], splits[2]) + } else { + usage, err = xunfeiHandler(c, *a.request, splits[0], splits[1], splits[2]) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/xunfei/constants.go b/relay/channel/xunfei/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..e19f01136c59af57e7ace9fae16176bff44e0497 --- /dev/null +++ b/relay/channel/xunfei/constants.go @@ -0,0 +1,12 @@ +package xunfei + +var ModelList = []string{ + "SparkDesk", + "SparkDesk-v1.1", + "SparkDesk-v2.1", + "SparkDesk-v3.1", + "SparkDesk-v3.5", + "SparkDesk-v4.0", +} + +var ChannelName = "xunfei" diff --git a/relay/channel/xunfei/dto.go b/relay/channel/xunfei/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..71a40f2d037a28aa83538ff84fb504fb5ec884bf --- /dev/null +++ b/relay/channel/xunfei/dto.go @@ -0,0 +1,59 @@ +package xunfei + +import "github.com/QuantumNous/new-api/dto" + +type XunfeiMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type XunfeiChatRequest struct { + Header struct { + AppId string `json:"app_id"` + } `json:"header"` + Parameter struct { + Chat struct { + Domain string `json:"domain,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopK int `json:"top_k,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + Auditing bool `json:"auditing,omitempty"` + } `json:"chat"` + } `json:"parameter"` + Payload struct { + Message struct { + Text []XunfeiMessage `json:"text"` + } `json:"message"` + } `json:"payload"` +} + +type XunfeiChatResponseTextItem struct { + Content string `json:"content"` + Role string `json:"role"` + Index int `json:"index"` +} + +type XunfeiChatResponse struct { + Header struct { + Code int `json:"code"` + Message string `json:"message"` + Sid string `json:"sid"` + Status int `json:"status"` + } `json:"header"` + Payload struct { + Choices struct { + Status int `json:"status"` + Seq int `json:"seq"` + Text []XunfeiChatResponseTextItem `json:"text"` + } `json:"choices"` + Usage struct { + //Text struct { + // QuestionTokens string `json:"question_tokens"` + // PromptTokens string `json:"prompt_tokens"` + // CompletionTokens string `json:"completion_tokens"` + // TotalTokens string `json:"total_tokens"` + //} `json:"text"` + Text dto.Usage `json:"text"` + } `json:"usage"` + } `json:"payload"` +} diff --git a/relay/channel/xunfei/relay-xunfei.go b/relay/channel/xunfei/relay-xunfei.go new file mode 100644 index 0000000000000000000000000000000000000000..b8fbd2958ace715772bb459c10daa3e052203e01 --- /dev/null +++ b/relay/channel/xunfei/relay-xunfei.go @@ -0,0 +1,291 @@ +package xunfei + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/url" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +// https://console.xfyun.cn/services/cbm +// https://www.xfyun.cn/doc/spark/Web.html + +func requestOpenAI2Xunfei(request dto.GeneralOpenAIRequest, xunfeiAppId string, domain string) *XunfeiChatRequest { + messages := make([]XunfeiMessage, 0, len(request.Messages)) + shouldCovertSystemMessage := !strings.HasSuffix(request.Model, "3.5") + for _, message := range request.Messages { + if message.Role == "system" && shouldCovertSystemMessage { + messages = append(messages, XunfeiMessage{ + Role: "user", + Content: message.StringContent(), + }) + messages = append(messages, XunfeiMessage{ + Role: "assistant", + Content: "Okay", + }) + } else { + messages = append(messages, XunfeiMessage{ + Role: message.Role, + Content: message.StringContent(), + }) + } + } + xunfeiRequest := XunfeiChatRequest{} + xunfeiRequest.Header.AppId = xunfeiAppId + xunfeiRequest.Parameter.Chat.Domain = domain + xunfeiRequest.Parameter.Chat.Temperature = request.Temperature + xunfeiRequest.Parameter.Chat.TopK = request.N + xunfeiRequest.Parameter.Chat.MaxTokens = request.GetMaxTokens() + xunfeiRequest.Payload.Message.Text = messages + return &xunfeiRequest +} + +func responseXunfei2OpenAI(response *XunfeiChatResponse) *dto.OpenAITextResponse { + if len(response.Payload.Choices.Text) == 0 { + response.Payload.Choices.Text = []XunfeiChatResponseTextItem{ + { + Content: "", + }, + } + } + choice := dto.OpenAITextResponseChoice{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: response.Payload.Choices.Text[0].Content, + }, + FinishReason: constant.FinishReasonStop, + } + fullTextResponse := dto.OpenAITextResponse{ + Object: "chat.completion", + Created: common.GetTimestamp(), + Choices: []dto.OpenAITextResponseChoice{choice}, + Usage: response.Payload.Usage.Text, + } + return &fullTextResponse +} + +func streamResponseXunfei2OpenAI(xunfeiResponse *XunfeiChatResponse) *dto.ChatCompletionsStreamResponse { + if len(xunfeiResponse.Payload.Choices.Text) == 0 { + xunfeiResponse.Payload.Choices.Text = []XunfeiChatResponseTextItem{ + { + Content: "", + }, + } + } + var choice dto.ChatCompletionsStreamResponseChoice + choice.Delta.SetContentString(xunfeiResponse.Payload.Choices.Text[0].Content) + if xunfeiResponse.Payload.Choices.Status == 2 { + choice.FinishReason = &constant.FinishReasonStop + } + response := dto.ChatCompletionsStreamResponse{ + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "SparkDesk", + Choices: []dto.ChatCompletionsStreamResponseChoice{choice}, + } + return &response +} + +func buildXunfeiAuthUrl(hostUrl string, apiKey, apiSecret string) string { + HmacWithShaToBase64 := func(algorithm, data, key string) string { + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte(data)) + encodeData := mac.Sum(nil) + return base64.StdEncoding.EncodeToString(encodeData) + } + ul, err := url.Parse(hostUrl) + if err != nil { + fmt.Println(err) + } + date := time.Now().UTC().Format(time.RFC1123) + signString := []string{"host: " + ul.Host, "date: " + date, "GET " + ul.Path + " HTTP/1.1"} + sign := strings.Join(signString, "\n") + sha := HmacWithShaToBase64("hmac-sha256", sign, apiSecret) + authUrl := fmt.Sprintf("hmac username=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"", apiKey, + "hmac-sha256", "host date request-line", sha) + authorization := base64.StdEncoding.EncodeToString([]byte(authUrl)) + v := url.Values{} + v.Add("host", ul.Host) + v.Add("date", date) + v.Add("authorization", authorization) + callUrl := hostUrl + "?" + v.Encode() + return callUrl +} + +func xunfeiStreamHandler(c *gin.Context, textRequest dto.GeneralOpenAIRequest, appId string, apiSecret string, apiKey string) (*dto.Usage, *types.NewAPIError) { + domain, authUrl := getXunfeiAuthUrl(c, apiKey, apiSecret, textRequest.Model) + dataChan, stopChan, err := xunfeiMakeRequest(textRequest, domain, authUrl, appId) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeDoRequestFailed) + } + helper.SetEventStreamHeaders(c) + var usage dto.Usage + c.Stream(func(w io.Writer) bool { + select { + case xunfeiResponse := <-dataChan: + usage.PromptTokens += xunfeiResponse.Payload.Usage.Text.PromptTokens + usage.CompletionTokens += xunfeiResponse.Payload.Usage.Text.CompletionTokens + usage.TotalTokens += xunfeiResponse.Payload.Usage.Text.TotalTokens + response := streamResponseXunfei2OpenAI(&xunfeiResponse) + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysLog("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + return &usage, nil +} + +func xunfeiHandler(c *gin.Context, textRequest dto.GeneralOpenAIRequest, appId string, apiSecret string, apiKey string) (*dto.Usage, *types.NewAPIError) { + domain, authUrl := getXunfeiAuthUrl(c, apiKey, apiSecret, textRequest.Model) + dataChan, stopChan, err := xunfeiMakeRequest(textRequest, domain, authUrl, appId) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeDoRequestFailed) + } + var usage dto.Usage + var content string + var xunfeiResponse XunfeiChatResponse + stop := false + for !stop { + select { + case xunfeiResponse = <-dataChan: + if len(xunfeiResponse.Payload.Choices.Text) == 0 { + continue + } + content += xunfeiResponse.Payload.Choices.Text[0].Content + usage.PromptTokens += xunfeiResponse.Payload.Usage.Text.PromptTokens + usage.CompletionTokens += xunfeiResponse.Payload.Usage.Text.CompletionTokens + usage.TotalTokens += xunfeiResponse.Payload.Usage.Text.TotalTokens + case stop = <-stopChan: + } + } + if len(xunfeiResponse.Payload.Choices.Text) == 0 { + xunfeiResponse.Payload.Choices.Text = []XunfeiChatResponseTextItem{ + { + Content: "", + }, + } + } + xunfeiResponse.Payload.Choices.Text[0].Content = content + + response := responseXunfei2OpenAI(&xunfeiResponse) + jsonResponse, err := json.Marshal(response) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + _, _ = c.Writer.Write(jsonResponse) + return &usage, nil +} + +func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, appId string) (chan XunfeiChatResponse, chan bool, error) { + d := websocket.Dialer{ + HandshakeTimeout: 5 * time.Second, + } + conn, resp, err := d.Dial(authUrl, nil) + if err != nil || resp.StatusCode != 101 { + return nil, nil, err + } + + data := requestOpenAI2Xunfei(textRequest, appId, domain) + err = conn.WriteJSON(data) + if err != nil { + return nil, nil, err + } + + dataChan := make(chan XunfeiChatResponse) + stopChan := make(chan bool) + go func() { + defer func() { + conn.Close() + }() + for { + _, msg, err := conn.ReadMessage() + if err != nil { + common.SysLog("error reading stream response: " + err.Error()) + break + } + var response XunfeiChatResponse + err = json.Unmarshal(msg, &response) + if err != nil { + common.SysLog("error unmarshalling stream response: " + err.Error()) + break + } + dataChan <- response + if response.Payload.Choices.Status == 2 { + if err != nil { + common.SysLog("error closing websocket connection: " + err.Error()) + } + break + } + } + stopChan <- true + }() + + return dataChan, stopChan, nil +} + +func apiVersion2domain(apiVersion string) string { + switch apiVersion { + case "v1.1": + return "lite" + case "v2.1": + return "generalv2" + case "v3.1": + return "generalv3" + case "v3.5": + return "generalv3.5" + case "v4.0": + return "4.0Ultra" + } + return "general" + apiVersion +} + +func getXunfeiAuthUrl(c *gin.Context, apiKey string, apiSecret string, modelName string) (string, string) { + apiVersion := getAPIVersion(c, modelName) + domain := apiVersion2domain(apiVersion) + authUrl := buildXunfeiAuthUrl(fmt.Sprintf("wss://spark-api.xf-yun.com/%s/chat", apiVersion), apiKey, apiSecret) + return domain, authUrl +} + +func getAPIVersion(c *gin.Context, modelName string) string { + query := c.Request.URL.Query() + apiVersion := query.Get("api-version") + if apiVersion != "" { + return apiVersion + } + parts := strings.Split(modelName, "-") + if len(parts) == 2 { + apiVersion = parts[1] + return apiVersion + + } + apiVersion = c.GetString("api_version") + if apiVersion != "" { + return apiVersion + } + apiVersion = "v1.1" + common.SysLog("api_version not found, using default: " + apiVersion) + return apiVersion +} diff --git a/relay/channel/zhipu/adaptor.go b/relay/channel/zhipu/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..23016fd3b4e8255e3e78808bf12eee4658da9576 --- /dev/null +++ b/relay/channel/zhipu/adaptor.go @@ -0,0 +1,102 @@ +package zhipu + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + method := "invoke" + if info.IsStream { + method = "sse-invoke" + } + return fmt.Sprintf("%s/api/paas/v3/model-api/%s/%s", info.ChannelBaseUrl, info.UpstreamModelName, method), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + token := getZhipuToken(info.ApiKey) + req.Set("Authorization", token) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if request.TopP >= 1 { + request.TopP = 0.99 + } + return requestOpenAI2Zhipu(*request), nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = zhipuStreamHandler(c, info, resp) + } else { + usage, err = zhipuHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/zhipu/constants.go b/relay/channel/zhipu/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..81b18d632c7f33f3f07a58e2c983b6e2398e35fb --- /dev/null +++ b/relay/channel/zhipu/constants.go @@ -0,0 +1,7 @@ +package zhipu + +var ModelList = []string{ + "chatglm_turbo", "chatglm_pro", "chatglm_std", "chatglm_lite", +} + +var ChannelName = "zhipu" diff --git a/relay/channel/zhipu/dto.go b/relay/channel/zhipu/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..5ca91362d4f657a4f82a054f044f6c288f63adfe --- /dev/null +++ b/relay/channel/zhipu/dto.go @@ -0,0 +1,47 @@ +package zhipu + +import ( + "time" + + "github.com/QuantumNous/new-api/dto" +) + +type ZhipuMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type ZhipuRequest struct { + Prompt []ZhipuMessage `json:"prompt"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + RequestId string `json:"request_id,omitempty"` + Incremental bool `json:"incremental,omitempty"` +} + +type ZhipuResponseData struct { + TaskId string `json:"task_id"` + RequestId string `json:"request_id"` + TaskStatus string `json:"task_status"` + Choices []ZhipuMessage `json:"choices"` + dto.Usage `json:"usage"` +} + +type ZhipuResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Success bool `json:"success"` + Data ZhipuResponseData `json:"data"` +} + +type ZhipuStreamMetaResponse struct { + RequestId string `json:"request_id"` + TaskId string `json:"task_id"` + TaskStatus string `json:"task_status"` + dto.Usage `json:"usage"` +} + +type zhipuTokenData struct { + Token string + ExpiryTime time.Time +} diff --git a/relay/channel/zhipu/relay-zhipu.go b/relay/channel/zhipu/relay-zhipu.go new file mode 100644 index 0000000000000000000000000000000000000000..964dff08278ccb51a80a5ab778f881c0a1a3d215 --- /dev/null +++ b/relay/channel/zhipu/relay-zhipu.go @@ -0,0 +1,247 @@ +package zhipu + +import ( + "bufio" + "encoding/json" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// https://open.bigmodel.cn/doc/api#chatglm_std +// chatglm_std, chatglm_lite +// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/invoke +// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/sse-invoke + +var zhipuTokens sync.Map +var expSeconds int64 = 24 * 3600 + +func getZhipuToken(apikey string) string { + data, ok := zhipuTokens.Load(apikey) + if ok { + tokenData := data.(zhipuTokenData) + if time.Now().Before(tokenData.ExpiryTime) { + return tokenData.Token + } + } + + split := strings.Split(apikey, ".") + if len(split) != 2 { + common.SysLog("invalid zhipu key: " + apikey) + return "" + } + + id := split[0] + secret := split[1] + + expMillis := time.Now().Add(time.Duration(expSeconds)*time.Second).UnixNano() / 1e6 + expiryTime := time.Now().Add(time.Duration(expSeconds) * time.Second) + + timestamp := time.Now().UnixNano() / 1e6 + + payload := jwt.MapClaims{ + "api_key": id, + "exp": expMillis, + "timestamp": timestamp, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, payload) + + token.Header["alg"] = "HS256" + token.Header["sign_type"] = "SIGN" + + tokenString, err := token.SignedString([]byte(secret)) + if err != nil { + return "" + } + + zhipuTokens.Store(apikey, zhipuTokenData{ + Token: tokenString, + ExpiryTime: expiryTime, + }) + + return tokenString +} + +func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *ZhipuRequest { + messages := make([]ZhipuMessage, 0, len(request.Messages)) + for _, message := range request.Messages { + if message.Role == "system" { + messages = append(messages, ZhipuMessage{ + Role: "system", + Content: message.StringContent(), + }) + messages = append(messages, ZhipuMessage{ + Role: "user", + Content: "Okay", + }) + } else { + messages = append(messages, ZhipuMessage{ + Role: message.Role, + Content: message.StringContent(), + }) + } + } + return &ZhipuRequest{ + Prompt: messages, + Temperature: request.Temperature, + TopP: request.TopP, + Incremental: false, + } +} + +func responseZhipu2OpenAI(response *ZhipuResponse) *dto.OpenAITextResponse { + fullTextResponse := dto.OpenAITextResponse{ + Id: response.Data.TaskId, + Object: "chat.completion", + Created: common.GetTimestamp(), + Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Data.Choices)), + Usage: response.Data.Usage, + } + for i, choice := range response.Data.Choices { + openaiChoice := dto.OpenAITextResponseChoice{ + Index: i, + Message: dto.Message{ + Role: choice.Role, + Content: strings.Trim(choice.Content, "\""), + }, + FinishReason: "", + } + if i == len(response.Data.Choices)-1 { + openaiChoice.FinishReason = "stop" + } + fullTextResponse.Choices = append(fullTextResponse.Choices, openaiChoice) + } + return &fullTextResponse +} + +func streamResponseZhipu2OpenAI(zhipuResponse string) *dto.ChatCompletionsStreamResponse { + var choice dto.ChatCompletionsStreamResponseChoice + choice.Delta.SetContentString(zhipuResponse) + response := dto.ChatCompletionsStreamResponse{ + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "chatglm", + Choices: []dto.ChatCompletionsStreamResponseChoice{choice}, + } + return &response +} + +func streamMetaResponseZhipu2OpenAI(zhipuResponse *ZhipuStreamMetaResponse) (*dto.ChatCompletionsStreamResponse, *dto.Usage) { + var choice dto.ChatCompletionsStreamResponseChoice + choice.Delta.SetContentString("") + choice.FinishReason = &constant.FinishReasonStop + response := dto.ChatCompletionsStreamResponse{ + Id: zhipuResponse.RequestId, + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "chatglm", + Choices: []dto.ChatCompletionsStreamResponseChoice{choice}, + } + return &response, &zhipuResponse.Usage +} + +func zhipuStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var usage *dto.Usage + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + dataChan := make(chan string) + metaChan := make(chan string) + stopChan := make(chan bool) + go func() { + for scanner.Scan() { + data := scanner.Text() + lines := strings.Split(data, "\n") + for i, line := range lines { + if len(line) < 5 { + continue + } + if line[:5] == "data:" { + dataChan <- line[5:] + if i != len(lines)-1 { + dataChan <- "\n" + } + } else if line[:5] == "meta:" { + metaChan <- line[5:] + } + } + } + stopChan <- true + }() + helper.SetEventStreamHeaders(c) + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + response := streamResponseZhipu2OpenAI(data) + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysLog("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case data := <-metaChan: + var zhipuResponse ZhipuStreamMetaResponse + err := json.Unmarshal([]byte(data), &zhipuResponse) + if err != nil { + common.SysLog("error unmarshalling stream response: " + err.Error()) + return true + } + response, zhipuUsage := streamMetaResponseZhipu2OpenAI(&zhipuResponse) + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysLog("error marshalling stream response: " + err.Error()) + return true + } + usage = zhipuUsage + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + service.CloseResponseBodyGracefully(resp) + return usage, nil +} + +func zhipuHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var zhipuResponse ZhipuResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &zhipuResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if !zhipuResponse.Success { + return nil, types.WithOpenAIError(types.OpenAIError{ + Message: zhipuResponse.Msg, + Code: zhipuResponse.Code, + }, resp.StatusCode) + } + fullTextResponse := responseZhipu2OpenAI(&zhipuResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return &fullTextResponse.Usage, nil +} diff --git a/relay/channel/zhipu_4v/adaptor.go b/relay/channel/zhipu_4v/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..575da7cbf161354606aaaf611e73513b830a4b0a --- /dev/null +++ b/relay/channel/zhipu_4v/adaptor.go @@ -0,0 +1,112 @@ +package zhipu_4v + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/claude" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + return req, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + switch info.RelayFormat { + case types.RelayFormatClaude: + return fmt.Sprintf("%s/api/anthropic/v1/messages", info.ChannelBaseUrl), nil + default: + switch info.RelayMode { + case relayconstant.RelayModeEmbeddings: + return fmt.Sprintf("%s/api/paas/v4/embeddings", info.ChannelBaseUrl), nil + default: + return fmt.Sprintf("%s/api/paas/v4/chat/completions", info.ChannelBaseUrl), nil + } + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if request.TopP >= 1 { + request.TopP = 0.99 + } + return requestOpenAI2Zhipu(*request), nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayFormat { + case types.RelayFormatClaude: + if info.IsStream { + return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + } else { + return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) + } + default: + adaptor := openai.Adaptor{} + return adaptor.DoResponse(c, resp, info) + } +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/zhipu_4v/constants.go b/relay/channel/zhipu_4v/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..816fa536bcc4bb0cb287136185981540a6d7a399 --- /dev/null +++ b/relay/channel/zhipu_4v/constants.go @@ -0,0 +1,7 @@ +package zhipu_4v + +var ModelList = []string{ + "glm-4", "glm-4v", "glm-3-turbo", "glm-4-alltools", "glm-4-plus", "glm-4-0520", "glm-4-air", "glm-4-airx", "glm-4-long", "glm-4-flash", "glm-4v-plus", +} + +var ChannelName = "zhipu_4v" diff --git a/relay/channel/zhipu_4v/dto.go b/relay/channel/zhipu_4v/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..e5edd0ddf77734bb75ff2d1312608150fe539c4c --- /dev/null +++ b/relay/channel/zhipu_4v/dto.go @@ -0,0 +1,60 @@ +package zhipu_4v + +import ( + "time" + + "github.com/QuantumNous/new-api/dto" +) + +// type ZhipuMessage struct { +// Role string `json:"role,omitempty"` +// Content string `json:"content,omitempty"` +// ToolCalls any `json:"tool_calls,omitempty"` +// ToolCallId any `json:"tool_call_id,omitempty"` +// } +// +// type ZhipuRequest struct { +// Model string `json:"model"` +// Stream bool `json:"stream,omitempty"` +// Messages []ZhipuMessage `json:"messages"` +// Temperature float64 `json:"temperature,omitempty"` +// TopP float64 `json:"top_p,omitempty"` +// MaxTokens int `json:"max_tokens,omitempty"` +// Stop []string `json:"stop,omitempty"` +// RequestId string `json:"request_id,omitempty"` +// Tools any `json:"tools,omitempty"` +// ToolChoice any `json:"tool_choice,omitempty"` +// } +// +// type ZhipuV4TextResponseChoice struct { +// Index int `json:"index"` +// ZhipuMessage `json:"message"` +// FinishReason string `json:"finish_reason"` +// } +type ZhipuV4Response struct { + Id string `json:"id"` + Created int64 `json:"created"` + Model string `json:"model"` + TextResponseChoices []dto.OpenAITextResponseChoice `json:"choices"` + Usage dto.Usage `json:"usage"` + Error dto.OpenAIError `json:"error"` +} + +// +//type ZhipuV4StreamResponseChoice struct { +// Index int `json:"index,omitempty"` +// Delta ZhipuMessage `json:"delta"` +// FinishReason *string `json:"finish_reason,omitempty"` +//} + +type ZhipuV4StreamResponse struct { + Id string `json:"id"` + Created int64 `json:"created"` + Choices []dto.ChatCompletionsStreamResponseChoice `json:"choices"` + Usage dto.Usage `json:"usage"` +} + +type tokenData struct { + Token string + ExpiryTime time.Time +} diff --git a/relay/channel/zhipu_4v/relay-zhipu_v4.go b/relay/channel/zhipu_4v/relay-zhipu_v4.go new file mode 100644 index 0000000000000000000000000000000000000000..53e94e14b3d7348e0dbd1b083f85faffe8ac6ebc --- /dev/null +++ b/relay/channel/zhipu_4v/relay-zhipu_v4.go @@ -0,0 +1,56 @@ +package zhipu_4v + +import ( + "strings" + + "github.com/QuantumNous/new-api/dto" +) + +func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest { + messages := make([]dto.Message, 0, len(request.Messages)) + for _, message := range request.Messages { + if !message.IsStringContent() { + mediaMessages := message.ParseContent() + for j, mediaMessage := range mediaMessages { + if mediaMessage.Type == dto.ContentTypeImageURL { + imageUrl := mediaMessage.GetImageMedia() + // check if base64 + if strings.HasPrefix(imageUrl.Url, "data:image/") { + // 去除base64数据的URL前缀(如果有) + if idx := strings.Index(imageUrl.Url, ","); idx != -1 { + imageUrl.Url = imageUrl.Url[idx+1:] + } + } + mediaMessage.ImageUrl = imageUrl + mediaMessages[j] = mediaMessage + } + } + message.SetMediaContent(mediaMessages) + } + messages = append(messages, dto.Message{ + Role: message.Role, + Content: message.Content, + ToolCalls: message.ToolCalls, + ToolCallId: message.ToolCallId, + }) + } + str, ok := request.Stop.(string) + var Stop []string + if ok { + Stop = []string{str} + } else { + Stop, _ = request.Stop.([]string) + } + return &dto.GeneralOpenAIRequest{ + Model: request.Model, + Stream: request.Stream, + Messages: messages, + Temperature: request.Temperature, + TopP: request.TopP, + MaxTokens: request.GetMaxTokens(), + Stop: Stop, + Tools: request.Tools, + ToolChoice: request.ToolChoice, + THINKING: request.THINKING, + } +} diff --git a/relay/claude_handler.go b/relay/claude_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..7a18c1737068c8483023af7e1a48f8c39cfd7aac --- /dev/null +++ b/relay/claude_handler.go @@ -0,0 +1,166 @@ +package relay + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + + info.InitChannelMeta(c) + + claudeReq, ok := info.Request.(*dto.ClaudeRequest) + + if !ok { + return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected *dto.ClaudeRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(claudeReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to ClaudeRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + if request.MaxTokens == 0 { + request.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model)) + } + + if model_setting.GetClaudeSettings().ThinkingAdapterEnabled && + strings.HasSuffix(request.Model, "-thinking") { + if request.Thinking == nil { + // 因为BudgetTokens 必须大于1024 + if request.MaxTokens < 1280 { + request.MaxTokens = 1280 + } + + // BudgetTokens 为 max_tokens 的 80% + request.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: common.GetPointer[int](int(float64(request.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)), + } + // TODO: 临时处理 + // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking + request.TopP = 0 + request.Temperature = common.GetPointer[float64](1.0) + } + if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) { + request.Model = strings.TrimSuffix(request.Model, "-thinking") + } + info.UpstreamModelName = request.Model + } + + if info.ChannelSetting.SystemPrompt != "" { + if request.System == nil { + request.SetStringSystem(info.ChannelSetting.SystemPrompt) + } else if info.ChannelSetting.SystemPromptOverride { + common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true) + if request.IsStringSystem() { + existing := strings.TrimSpace(request.GetStringSystem()) + if existing == "" { + request.SetStringSystem(info.ChannelSetting.SystemPrompt) + } else { + request.SetStringSystem(info.ChannelSetting.SystemPrompt + "\n" + existing) + } + } else { + systemContents := request.ParseSystem() + newSystem := dto.ClaudeMediaMessage{Type: dto.ContentTypeText} + newSystem.SetText(info.ChannelSetting.SystemPrompt) + if len(systemContents) == 0 { + request.System = []dto.ClaudeMediaMessage{newSystem} + } else { + request.System = append([]dto.ClaudeMediaMessage{newSystem}, systemContents...) + } + } + } + } + + var requestBody io.Reader + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + requestBody = bytes.NewBuffer(body) + } else { + convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + // remove disabled fields for Claude API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + // apply param override + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + + if common.DebugEnabled { + println("requestBody: ", string(jsonData)) + } + requestBody = bytes.NewBuffer(jsonData) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + var httpResp *http.Response + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + if resp != nil { + httpResp = resp.(*http.Response) + info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + //log.Printf("usage: %v", usage) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + + service.PostClaudeConsumeQuota(c, info, usage.(*dto.Usage)) + return nil +} diff --git a/relay/common/override.go b/relay/common/override.go new file mode 100644 index 0000000000000000000000000000000000000000..1d0794d2638364868140c70f2c87f4b47dc2cbe2 --- /dev/null +++ b/relay/common/override.go @@ -0,0 +1,481 @@ +package common + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type ConditionOperation struct { + Path string `json:"path"` // JSON路径 + Mode string `json:"mode"` // full, prefix, suffix, contains, gt, gte, lt, lte + Value interface{} `json:"value"` // 匹配的值 + Invert bool `json:"invert"` // 反选功能,true表示取反结果 + PassMissingKey bool `json:"pass_missing_key"` // 未获取到json key时的行为 +} + +type ParamOperation struct { + Path string `json:"path"` + Mode string `json:"mode"` // delete, set, move, prepend, append + Value interface{} `json:"value"` + KeepOrigin bool `json:"keep_origin"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Conditions []ConditionOperation `json:"conditions,omitempty"` // 条件列表 + Logic string `json:"logic,omitempty"` // AND, OR (默认OR) +} + +func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, conditionContext map[string]interface{}) ([]byte, error) { + if len(paramOverride) == 0 { + return jsonData, nil + } + + // 尝试断言为操作格式 + if operations, ok := tryParseOperations(paramOverride); ok { + // 使用新方法 + result, err := applyOperations(string(jsonData), operations, conditionContext) + return []byte(result), err + } + + // 直接使用旧方法 + return applyOperationsLegacy(jsonData, paramOverride) +} + +func tryParseOperations(paramOverride map[string]interface{}) ([]ParamOperation, bool) { + // 检查是否包含 "operations" 字段 + if opsValue, exists := paramOverride["operations"]; exists { + if opsSlice, ok := opsValue.([]interface{}); ok { + var operations []ParamOperation + for _, op := range opsSlice { + if opMap, ok := op.(map[string]interface{}); ok { + operation := ParamOperation{} + + // 断言必要字段 + if path, ok := opMap["path"].(string); ok { + operation.Path = path + } + if mode, ok := opMap["mode"].(string); ok { + operation.Mode = mode + } else { + return nil, false // mode 是必需的 + } + + // 可选字段 + if value, exists := opMap["value"]; exists { + operation.Value = value + } + if keepOrigin, ok := opMap["keep_origin"].(bool); ok { + operation.KeepOrigin = keepOrigin + } + if from, ok := opMap["from"].(string); ok { + operation.From = from + } + if to, ok := opMap["to"].(string); ok { + operation.To = to + } + if logic, ok := opMap["logic"].(string); ok { + operation.Logic = logic + } else { + operation.Logic = "OR" // 默认为OR + } + + // 解析条件 + if conditions, exists := opMap["conditions"]; exists { + if condSlice, ok := conditions.([]interface{}); ok { + for _, cond := range condSlice { + if condMap, ok := cond.(map[string]interface{}); ok { + condition := ConditionOperation{} + if path, ok := condMap["path"].(string); ok { + condition.Path = path + } + if mode, ok := condMap["mode"].(string); ok { + condition.Mode = mode + } + if value, ok := condMap["value"]; ok { + condition.Value = value + } + if invert, ok := condMap["invert"].(bool); ok { + condition.Invert = invert + } + if passMissingKey, ok := condMap["pass_missing_key"].(bool); ok { + condition.PassMissingKey = passMissingKey + } + operation.Conditions = append(operation.Conditions, condition) + } + } + } + } + + operations = append(operations, operation) + } else { + return nil, false + } + } + return operations, true + } + } + + return nil, false +} + +func checkConditions(jsonStr, contextJSON string, conditions []ConditionOperation, logic string) (bool, error) { + if len(conditions) == 0 { + return true, nil // 没有条件,直接通过 + } + results := make([]bool, len(conditions)) + for i, condition := range conditions { + result, err := checkSingleCondition(jsonStr, contextJSON, condition) + if err != nil { + return false, err + } + results[i] = result + } + + if strings.ToUpper(logic) == "AND" { + for _, result := range results { + if !result { + return false, nil + } + } + return true, nil + } else { + for _, result := range results { + if result { + return true, nil + } + } + return false, nil + } +} + +func checkSingleCondition(jsonStr, contextJSON string, condition ConditionOperation) (bool, error) { + // 处理负数索引 + path := processNegativeIndex(jsonStr, condition.Path) + value := gjson.Get(jsonStr, path) + if !value.Exists() && contextJSON != "" { + value = gjson.Get(contextJSON, condition.Path) + } + if !value.Exists() { + if condition.PassMissingKey { + return true, nil + } + return false, nil + } + + // 利用gjson的类型解析 + targetBytes, err := common.Marshal(condition.Value) + if err != nil { + return false, fmt.Errorf("failed to marshal condition value: %v", err) + } + targetValue := gjson.ParseBytes(targetBytes) + + result, err := compareGjsonValues(value, targetValue, strings.ToLower(condition.Mode)) + if err != nil { + return false, fmt.Errorf("comparison failed for path %s: %v", condition.Path, err) + } + + if condition.Invert { + result = !result + } + return result, nil +} + +func processNegativeIndex(jsonStr string, path string) string { + re := regexp.MustCompile(`\.(-\d+)`) + matches := re.FindAllStringSubmatch(path, -1) + + if len(matches) == 0 { + return path + } + + result := path + for _, match := range matches { + negIndex := match[1] + index, _ := strconv.Atoi(negIndex) + + arrayPath := strings.Split(path, negIndex)[0] + if strings.HasSuffix(arrayPath, ".") { + arrayPath = arrayPath[:len(arrayPath)-1] + } + + array := gjson.Get(jsonStr, arrayPath) + if array.IsArray() { + length := len(array.Array()) + actualIndex := length + index + if actualIndex >= 0 && actualIndex < length { + result = strings.Replace(result, match[0], "."+strconv.Itoa(actualIndex), 1) + } + } + } + + return result +} + +// compareGjsonValues 直接比较两个gjson.Result,支持所有比较模式 +func compareGjsonValues(jsonValue, targetValue gjson.Result, mode string) (bool, error) { + switch mode { + case "full": + return compareEqual(jsonValue, targetValue) + case "prefix": + return strings.HasPrefix(jsonValue.String(), targetValue.String()), nil + case "suffix": + return strings.HasSuffix(jsonValue.String(), targetValue.String()), nil + case "contains": + return strings.Contains(jsonValue.String(), targetValue.String()), nil + case "gt": + return compareNumeric(jsonValue, targetValue, "gt") + case "gte": + return compareNumeric(jsonValue, targetValue, "gte") + case "lt": + return compareNumeric(jsonValue, targetValue, "lt") + case "lte": + return compareNumeric(jsonValue, targetValue, "lte") + default: + return false, fmt.Errorf("unsupported comparison mode: %s", mode) + } +} + +func compareEqual(jsonValue, targetValue gjson.Result) (bool, error) { + // 对null值特殊处理:两个都是null返回true,一个是null另一个不是返回false + if jsonValue.Type == gjson.Null || targetValue.Type == gjson.Null { + return jsonValue.Type == gjson.Null && targetValue.Type == gjson.Null, nil + } + + // 对布尔值特殊处理 + if (jsonValue.Type == gjson.True || jsonValue.Type == gjson.False) && + (targetValue.Type == gjson.True || targetValue.Type == gjson.False) { + return jsonValue.Bool() == targetValue.Bool(), nil + } + + // 如果类型不同,报错 + if jsonValue.Type != targetValue.Type { + return false, fmt.Errorf("compare for different types, got %v and %v", jsonValue.Type, targetValue.Type) + } + + switch jsonValue.Type { + case gjson.True, gjson.False: + return jsonValue.Bool() == targetValue.Bool(), nil + case gjson.Number: + return jsonValue.Num == targetValue.Num, nil + case gjson.String: + return jsonValue.String() == targetValue.String(), nil + default: + return jsonValue.String() == targetValue.String(), nil + } +} + +func compareNumeric(jsonValue, targetValue gjson.Result, operator string) (bool, error) { + // 只有数字类型才支持数值比较 + if jsonValue.Type != gjson.Number || targetValue.Type != gjson.Number { + return false, fmt.Errorf("numeric comparison requires both values to be numbers, got %v and %v", jsonValue.Type, targetValue.Type) + } + + jsonNum := jsonValue.Num + targetNum := targetValue.Num + + switch operator { + case "gt": + return jsonNum > targetNum, nil + case "gte": + return jsonNum >= targetNum, nil + case "lt": + return jsonNum < targetNum, nil + case "lte": + return jsonNum <= targetNum, nil + default: + return false, fmt.Errorf("unsupported numeric operator: %s", operator) + } +} + +// applyOperationsLegacy 原参数覆盖方法 +func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}) ([]byte, error) { + reqMap := make(map[string]interface{}) + err := common.Unmarshal(jsonData, &reqMap) + if err != nil { + return nil, err + } + + for key, value := range paramOverride { + reqMap[key] = value + } + + return common.Marshal(reqMap) +} + +func applyOperations(jsonStr string, operations []ParamOperation, conditionContext map[string]interface{}) (string, error) { + var contextJSON string + if conditionContext != nil && len(conditionContext) > 0 { + ctxBytes, err := common.Marshal(conditionContext) + if err != nil { + return "", fmt.Errorf("failed to marshal condition context: %v", err) + } + contextJSON = string(ctxBytes) + } + + result := jsonStr + for _, op := range operations { + // 检查条件是否满足 + ok, err := checkConditions(result, contextJSON, op.Conditions, op.Logic) + if err != nil { + return "", err + } + if !ok { + continue // 条件不满足,跳过当前操作 + } + // 处理路径中的负数索引 + opPath := processNegativeIndex(result, op.Path) + opFrom := processNegativeIndex(result, op.From) + opTo := processNegativeIndex(result, op.To) + + switch op.Mode { + case "delete": + result, err = sjson.Delete(result, opPath) + case "set": + if op.KeepOrigin && gjson.Get(result, opPath).Exists() { + continue + } + result, err = sjson.Set(result, opPath, op.Value) + case "move": + result, err = moveValue(result, opFrom, opTo) + case "prepend": + result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, true) + case "append": + result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, false) + default: + return "", fmt.Errorf("unknown operation: %s", op.Mode) + } + if err != nil { + return "", fmt.Errorf("operation %s failed: %v", op.Mode, err) + } + } + return result, nil +} + +func moveValue(jsonStr, fromPath, toPath string) (string, error) { + sourceValue := gjson.Get(jsonStr, fromPath) + if !sourceValue.Exists() { + return jsonStr, fmt.Errorf("source path does not exist: %s", fromPath) + } + result, err := sjson.Set(jsonStr, toPath, sourceValue.Value()) + if err != nil { + return "", err + } + return sjson.Delete(result, fromPath) +} + +func modifyValue(jsonStr, path string, value interface{}, keepOrigin, isPrepend bool) (string, error) { + current := gjson.Get(jsonStr, path) + switch { + case current.IsArray(): + return modifyArray(jsonStr, path, value, isPrepend) + case current.Type == gjson.String: + return modifyString(jsonStr, path, value, isPrepend) + case current.Type == gjson.JSON: + return mergeObjects(jsonStr, path, value, keepOrigin) + } + return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type) +} + +func modifyArray(jsonStr, path string, value interface{}, isPrepend bool) (string, error) { + current := gjson.Get(jsonStr, path) + var newArray []interface{} + // 添加新值 + addValue := func() { + if arr, ok := value.([]interface{}); ok { + newArray = append(newArray, arr...) + } else { + newArray = append(newArray, value) + } + } + // 添加原值 + addOriginal := func() { + current.ForEach(func(_, val gjson.Result) bool { + newArray = append(newArray, val.Value()) + return true + }) + } + if isPrepend { + addValue() + addOriginal() + } else { + addOriginal() + addValue() + } + return sjson.Set(jsonStr, path, newArray) +} + +func modifyString(jsonStr, path string, value interface{}, isPrepend bool) (string, error) { + current := gjson.Get(jsonStr, path) + valueStr := fmt.Sprintf("%v", value) + var newStr string + if isPrepend { + newStr = valueStr + current.String() + } else { + newStr = current.String() + valueStr + } + return sjson.Set(jsonStr, path, newStr) +} + +func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (string, error) { + current := gjson.Get(jsonStr, path) + var currentMap, newMap map[string]interface{} + + // 解析当前值 + if err := common.Unmarshal([]byte(current.Raw), ¤tMap); err != nil { + return "", err + } + // 解析新值 + switch v := value.(type) { + case map[string]interface{}: + newMap = v + default: + jsonBytes, _ := common.Marshal(v) + if err := common.Unmarshal(jsonBytes, &newMap); err != nil { + return "", err + } + } + // 合并 + result := make(map[string]interface{}) + for k, v := range currentMap { + result[k] = v + } + for k, v := range newMap { + if !keepOrigin || result[k] == nil { + result[k] = v + } + } + return sjson.Set(jsonStr, path, result) +} + +// BuildParamOverrideContext 提供 ApplyParamOverride 可用的上下文信息。 +// 目前内置以下字段: +// - model:优先使用上游模型名(UpstreamModelName),若不存在则回落到原始模型名(OriginModelName)。 +// - upstream_model:始终为通道映射后的上游模型名。 +// - original_model:请求最初指定的模型名。 +func BuildParamOverrideContext(info *RelayInfo) map[string]interface{} { + if info == nil || info.ChannelMeta == nil { + return nil + } + + ctx := make(map[string]interface{}) + if info.UpstreamModelName != "" { + ctx["model"] = info.UpstreamModelName + ctx["upstream_model"] = info.UpstreamModelName + } + if info.OriginModelName != "" { + ctx["original_model"] = info.OriginModelName + if _, exists := ctx["model"]; !exists { + ctx["model"] = info.OriginModelName + } + } + + if len(ctx) == 0 { + return nil + } + return ctx +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go new file mode 100644 index 0000000000000000000000000000000000000000..33ef4d14cdc7f4906a98c8feae734adc5107976f --- /dev/null +++ b/relay/common/relay_info.go @@ -0,0 +1,612 @@ +package common + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +type ThinkingContentInfo struct { + IsFirstThinkingContent bool + SendLastThinkingContent bool + HasSentThinkingContent bool +} + +const ( + LastMessageTypeNone = "none" + LastMessageTypeText = "text" + LastMessageTypeTools = "tools" + LastMessageTypeThinking = "thinking" +) + +type ClaudeConvertInfo struct { + LastMessagesType string + Index int + Usage *dto.Usage + FinishReason string + Done bool +} + +type RerankerInfo struct { + Documents []any + ReturnDocuments bool +} + +type BuildInToolInfo struct { + ToolName string + CallCount int + SearchContextSize string +} + +type ResponsesUsageInfo struct { + BuiltInTools map[string]*BuildInToolInfo +} + +type ChannelMeta struct { + ChannelType int + ChannelId int + ChannelIsMultiKey bool + ChannelMultiKeyIndex int + ChannelBaseUrl string + ApiType int + ApiVersion string + ApiKey string + Organization string + ChannelCreateTime int64 + ParamOverride map[string]interface{} + HeadersOverride map[string]interface{} + ChannelSetting dto.ChannelSettings + ChannelOtherSettings dto.ChannelOtherSettings + UpstreamModelName string + IsModelMapped bool + SupportStreamOptions bool // 是否支持流式选项 +} + +type RelayInfo struct { + TokenId int + TokenKey string + UserId int + UsingGroup string // 使用的分组 + UserGroup string // 用户所在分组 + TokenUnlimited bool + StartTime time.Time + FirstResponseTime time.Time + isFirstResponse bool + //SendLastReasoningResponse bool + IsStream bool + IsGeminiBatchEmbedding bool + IsPlayground bool + UsePrice bool + RelayMode int + OriginModelName string + RequestURLPath string + PromptTokens int + ShouldIncludeUsage bool + DisablePing bool // 是否禁止向下游发送自定义 Ping + ClientWs *websocket.Conn + TargetWs *websocket.Conn + InputAudioFormat string + OutputAudioFormat string + RealtimeTools []dto.RealTimeTool + IsFirstRequest bool + AudioUsage bool + ReasoningEffort string + UserSetting dto.UserSetting + UserEmail string + UserQuota int + RelayFormat types.RelayFormat + SendResponseCount int + FinalPreConsumedQuota int // 最终预消耗的配额 + IsClaudeBetaQuery bool // /v1/messages?beta=true + + PriceData types.PriceData + + Request dto.Request + + ThinkingContentInfo + *ClaudeConvertInfo + *RerankerInfo + *ResponsesUsageInfo + *ChannelMeta + *TaskRelayInfo +} + +func (info *RelayInfo) InitChannelMeta(c *gin.Context) { + channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType) + paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride) + headerOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelHeaderOverride) + apiType, _ := common.ChannelType2APIType(channelType) + channelMeta := &ChannelMeta{ + ChannelType: channelType, + ChannelId: common.GetContextKeyInt(c, constant.ContextKeyChannelId), + ChannelIsMultiKey: common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey), + ChannelMultiKeyIndex: common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex), + ChannelBaseUrl: common.GetContextKeyString(c, constant.ContextKeyChannelBaseUrl), + ApiType: apiType, + ApiVersion: c.GetString("api_version"), + ApiKey: common.GetContextKeyString(c, constant.ContextKeyChannelKey), + Organization: c.GetString("channel_organization"), + ChannelCreateTime: c.GetInt64("channel_create_time"), + ParamOverride: paramOverride, + HeadersOverride: headerOverride, + UpstreamModelName: common.GetContextKeyString(c, constant.ContextKeyOriginalModel), + IsModelMapped: false, + SupportStreamOptions: false, + } + + if channelType == constant.ChannelTypeAzure { + channelMeta.ApiVersion = GetAPIVersion(c) + } + if channelType == constant.ChannelTypeVertexAi { + channelMeta.ApiVersion = c.GetString("region") + } + + channelSetting, ok := common.GetContextKeyType[dto.ChannelSettings](c, constant.ContextKeyChannelSetting) + if ok { + channelMeta.ChannelSetting = channelSetting + } + + channelOtherSettings, ok := common.GetContextKeyType[dto.ChannelOtherSettings](c, constant.ContextKeyChannelOtherSetting) + if ok { + channelMeta.ChannelOtherSettings = channelOtherSettings + } + + if streamSupportedChannels[channelMeta.ChannelType] { + channelMeta.SupportStreamOptions = true + } + + info.ChannelMeta = channelMeta + + // reset some fields based on channel meta + // 重置某些字段,例如模型名称等 + if info.Request != nil { + info.Request.SetModelName(info.OriginModelName) + } +} + +func (info *RelayInfo) ToString() string { + if info == nil { + return "RelayInfo" + } + + // Basic info + b := &strings.Builder{} + fmt.Fprintf(b, "RelayInfo{ ") + fmt.Fprintf(b, "RelayFormat: %s, ", info.RelayFormat) + fmt.Fprintf(b, "RelayMode: %d, ", info.RelayMode) + fmt.Fprintf(b, "IsStream: %t, ", info.IsStream) + fmt.Fprintf(b, "IsPlayground: %t, ", info.IsPlayground) + fmt.Fprintf(b, "RequestURLPath: %q, ", info.RequestURLPath) + fmt.Fprintf(b, "OriginModelName: %q, ", info.OriginModelName) + fmt.Fprintf(b, "PromptTokens: %d, ", info.PromptTokens) + fmt.Fprintf(b, "ShouldIncludeUsage: %t, ", info.ShouldIncludeUsage) + fmt.Fprintf(b, "DisablePing: %t, ", info.DisablePing) + fmt.Fprintf(b, "SendResponseCount: %d, ", info.SendResponseCount) + fmt.Fprintf(b, "FinalPreConsumedQuota: %d, ", info.FinalPreConsumedQuota) + + // User & token info (mask secrets) + fmt.Fprintf(b, "User{ Id: %d, Email: %q, Group: %q, UsingGroup: %q, Quota: %d }, ", + info.UserId, common.MaskEmail(info.UserEmail), info.UserGroup, info.UsingGroup, info.UserQuota) + fmt.Fprintf(b, "Token{ Id: %d, Unlimited: %t, Key: ***masked*** }, ", info.TokenId, info.TokenUnlimited) + + // Time info + latencyMs := info.FirstResponseTime.Sub(info.StartTime).Milliseconds() + fmt.Fprintf(b, "Timing{ Start: %s, FirstResponse: %s, LatencyMs: %d }, ", + info.StartTime.Format(time.RFC3339Nano), info.FirstResponseTime.Format(time.RFC3339Nano), latencyMs) + + // Audio / realtime + if info.InputAudioFormat != "" || info.OutputAudioFormat != "" || len(info.RealtimeTools) > 0 || info.AudioUsage { + fmt.Fprintf(b, "Realtime{ AudioUsage: %t, InFmt: %q, OutFmt: %q, Tools: %d }, ", + info.AudioUsage, info.InputAudioFormat, info.OutputAudioFormat, len(info.RealtimeTools)) + } + + // Reasoning + if info.ReasoningEffort != "" { + fmt.Fprintf(b, "ReasoningEffort: %q, ", info.ReasoningEffort) + } + + // Price data (non-sensitive) + if info.PriceData.UsePrice { + fmt.Fprintf(b, "PriceData{ %s }, ", info.PriceData.ToSetting()) + } + + // Channel metadata (mask ApiKey) + if info.ChannelMeta != nil { + cm := info.ChannelMeta + fmt.Fprintf(b, "ChannelMeta{ Type: %d, Id: %d, IsMultiKey: %t, MultiKeyIndex: %d, BaseURL: %q, ApiType: %d, ApiVersion: %q, Organization: %q, CreateTime: %d, UpstreamModelName: %q, IsModelMapped: %t, SupportStreamOptions: %t, ApiKey: ***masked*** }, ", + cm.ChannelType, cm.ChannelId, cm.ChannelIsMultiKey, cm.ChannelMultiKeyIndex, cm.ChannelBaseUrl, cm.ApiType, cm.ApiVersion, cm.Organization, cm.ChannelCreateTime, cm.UpstreamModelName, cm.IsModelMapped, cm.SupportStreamOptions) + } + + // Responses usage info (non-sensitive) + if info.ResponsesUsageInfo != nil && len(info.ResponsesUsageInfo.BuiltInTools) > 0 { + fmt.Fprintf(b, "ResponsesTools{ ") + first := true + for name, tool := range info.ResponsesUsageInfo.BuiltInTools { + if !first { + fmt.Fprintf(b, ", ") + } + first = false + if tool != nil { + fmt.Fprintf(b, "%s: calls=%d", name, tool.CallCount) + } else { + fmt.Fprintf(b, "%s: calls=0", name) + } + } + fmt.Fprintf(b, " }, ") + } + + fmt.Fprintf(b, "}") + return b.String() +} + +// 定义支持流式选项的通道类型 +var streamSupportedChannels = map[int]bool{ + constant.ChannelTypeOpenAI: true, + constant.ChannelTypeAnthropic: true, + constant.ChannelTypeAws: true, + constant.ChannelTypeGemini: true, + constant.ChannelCloudflare: true, + constant.ChannelTypeAzure: true, + constant.ChannelTypeVolcEngine: true, + constant.ChannelTypeOllama: true, + constant.ChannelTypeXai: true, + constant.ChannelTypeDeepSeek: true, + constant.ChannelTypeBaiduV2: true, + constant.ChannelTypeZhipu_v4: true, + constant.ChannelTypeAli: true, + constant.ChannelTypeSubmodel: true, +} + +func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo { + info := genBaseRelayInfo(c, nil) + info.RelayFormat = types.RelayFormatOpenAIRealtime + info.ClientWs = ws + info.InputAudioFormat = "pcm16" + info.OutputAudioFormat = "pcm16" + info.IsFirstRequest = true + return info +} + +func GenRelayInfoClaude(c *gin.Context, request dto.Request) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayFormat = types.RelayFormatClaude + info.ShouldIncludeUsage = false + info.ClaudeConvertInfo = &ClaudeConvertInfo{ + LastMessagesType: LastMessageTypeNone, + } + if c.Query("beta") == "true" { + info.IsClaudeBetaQuery = true + } + return info +} + +func GenRelayInfoRerank(c *gin.Context, request *dto.RerankRequest) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayMode = relayconstant.RelayModeRerank + info.RelayFormat = types.RelayFormatRerank + info.RerankerInfo = &RerankerInfo{ + Documents: request.Documents, + ReturnDocuments: request.GetReturnDocuments(), + } + return info +} + +func GenRelayInfoOpenAIAudio(c *gin.Context, request dto.Request) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayFormat = types.RelayFormatOpenAIAudio + return info +} + +func GenRelayInfoEmbedding(c *gin.Context, request dto.Request) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayFormat = types.RelayFormatEmbedding + return info +} + +func GenRelayInfoResponses(c *gin.Context, request *dto.OpenAIResponsesRequest) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayMode = relayconstant.RelayModeResponses + info.RelayFormat = types.RelayFormatOpenAIResponses + + info.ResponsesUsageInfo = &ResponsesUsageInfo{ + BuiltInTools: make(map[string]*BuildInToolInfo), + } + if len(request.Tools) > 0 { + for _, tool := range request.GetToolsMap() { + toolType := common.Interface2String(tool["type"]) + info.ResponsesUsageInfo.BuiltInTools[toolType] = &BuildInToolInfo{ + ToolName: toolType, + CallCount: 0, + } + switch toolType { + case dto.BuildInToolWebSearchPreview: + searchContextSize := common.Interface2String(tool["search_context_size"]) + if searchContextSize == "" { + searchContextSize = "medium" + } + info.ResponsesUsageInfo.BuiltInTools[toolType].SearchContextSize = searchContextSize + } + } + } + return info +} + +func GenRelayInfoGemini(c *gin.Context, request dto.Request) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayFormat = types.RelayFormatGemini + info.ShouldIncludeUsage = false + + return info +} + +func GenRelayInfoImage(c *gin.Context, request dto.Request) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayFormat = types.RelayFormatOpenAIImage + return info +} + +func GenRelayInfoOpenAI(c *gin.Context, request dto.Request) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayFormat = types.RelayFormatOpenAI + return info +} + +func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { + + //channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType) + //channelId := common.GetContextKeyInt(c, constant.ContextKeyChannelId) + //paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride) + + startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime) + if startTime.IsZero() { + startTime = time.Now() + } + + isStream := false + + if request != nil { + isStream = request.IsStream(c) + } + + // firstResponseTime = time.Now() - 1 second + + info := &RelayInfo{ + Request: request, + + UserId: common.GetContextKeyInt(c, constant.ContextKeyUserId), + UsingGroup: common.GetContextKeyString(c, constant.ContextKeyUsingGroup), + UserGroup: common.GetContextKeyString(c, constant.ContextKeyUserGroup), + UserQuota: common.GetContextKeyInt(c, constant.ContextKeyUserQuota), + UserEmail: common.GetContextKeyString(c, constant.ContextKeyUserEmail), + + OriginModelName: common.GetContextKeyString(c, constant.ContextKeyOriginalModel), + PromptTokens: common.GetContextKeyInt(c, constant.ContextKeyPromptTokens), + + TokenId: common.GetContextKeyInt(c, constant.ContextKeyTokenId), + TokenKey: common.GetContextKeyString(c, constant.ContextKeyTokenKey), + TokenUnlimited: common.GetContextKeyBool(c, constant.ContextKeyTokenUnlimited), + + isFirstResponse: true, + RelayMode: relayconstant.Path2RelayMode(c.Request.URL.Path), + RequestURLPath: c.Request.URL.String(), + IsStream: isStream, + + StartTime: startTime, + FirstResponseTime: startTime.Add(-time.Second), + ThinkingContentInfo: ThinkingContentInfo{ + IsFirstThinkingContent: true, + SendLastThinkingContent: false, + }, + } + + if info.RelayMode == relayconstant.RelayModeUnknown { + info.RelayMode = c.GetInt("relay_mode") + } + + if strings.HasPrefix(c.Request.URL.Path, "/pg") { + info.IsPlayground = true + info.RequestURLPath = strings.TrimPrefix(info.RequestURLPath, "/pg") + info.RequestURLPath = "/v1" + info.RequestURLPath + } + + userSetting, ok := common.GetContextKeyType[dto.UserSetting](c, constant.ContextKeyUserSetting) + if ok { + info.UserSetting = userSetting + } + + return info +} + +func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Request, ws *websocket.Conn) (*RelayInfo, error) { + switch relayFormat { + case types.RelayFormatOpenAI: + return GenRelayInfoOpenAI(c, request), nil + case types.RelayFormatOpenAIAudio: + return GenRelayInfoOpenAIAudio(c, request), nil + case types.RelayFormatOpenAIImage: + return GenRelayInfoImage(c, request), nil + case types.RelayFormatOpenAIRealtime: + return GenRelayInfoWs(c, ws), nil + case types.RelayFormatClaude: + return GenRelayInfoClaude(c, request), nil + case types.RelayFormatRerank: + if request, ok := request.(*dto.RerankRequest); ok { + return GenRelayInfoRerank(c, request), nil + } + return nil, errors.New("request is not a RerankRequest") + case types.RelayFormatGemini: + return GenRelayInfoGemini(c, request), nil + case types.RelayFormatEmbedding: + return GenRelayInfoEmbedding(c, request), nil + case types.RelayFormatOpenAIResponses: + if request, ok := request.(*dto.OpenAIResponsesRequest); ok { + return GenRelayInfoResponses(c, request), nil + } + return nil, errors.New("request is not a OpenAIResponsesRequest") + case types.RelayFormatTask: + return genBaseRelayInfo(c, nil), nil + case types.RelayFormatMjProxy: + return genBaseRelayInfo(c, nil), nil + default: + return nil, errors.New("invalid relay format") + } +} + +func (info *RelayInfo) SetPromptTokens(promptTokens int) { + info.PromptTokens = promptTokens +} + +func (info *RelayInfo) SetFirstResponseTime() { + if info.isFirstResponse { + info.FirstResponseTime = time.Now() + info.isFirstResponse = false + } +} + +func (info *RelayInfo) HasSendResponse() bool { + return info.FirstResponseTime.After(info.StartTime) +} + +type TaskRelayInfo struct { + Action string + OriginTaskID string + + ConsumeQuota bool +} + +type TaskSubmitReq struct { + Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` + Mode string `json:"mode,omitempty"` + Image string `json:"image,omitempty"` + Images []string `json:"images,omitempty"` + Size string `json:"size,omitempty"` + Duration int `json:"duration,omitempty"` + Seconds string `json:"seconds,omitempty"` + InputReference string `json:"input_reference,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +func (t *TaskSubmitReq) GetPrompt() string { + return t.Prompt +} + +func (t *TaskSubmitReq) HasImage() bool { + return len(t.Images) > 0 +} + +func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error { + type Alias TaskSubmitReq + aux := &struct { + Metadata json.RawMessage `json:"metadata,omitempty"` + *Alias + }{ + Alias: (*Alias)(t), + } + + if err := common.Unmarshal(data, &aux); err != nil { + return err + } + + if len(aux.Metadata) > 0 { + var metadataStr string + if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" { + var metadataObj map[string]interface{} + if err := common.Unmarshal([]byte(metadataStr), &metadataObj); err == nil { + t.Metadata = metadataObj + return nil + } + } + + var metadataObj map[string]interface{} + if err := common.Unmarshal(aux.Metadata, &metadataObj); err == nil { + t.Metadata = metadataObj + } + } + + return nil +} +func (t *TaskSubmitReq) UnmarshalMetadata(v any) error { + metadata := t.Metadata + if metadata != nil { + metadataBytes, err := json.Marshal(metadata) + if err != nil { + return fmt.Errorf("marshal metadata failed: %w", err) + } + err = json.Unmarshal(metadataBytes, v) + if err != nil { + return fmt.Errorf("unmarshal metadata to target failed: %w", err) + } + } + return nil +} + +type TaskInfo struct { + Code int `json:"code"` + TaskID string `json:"task_id"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Url string `json:"url,omitempty"` + RemoteUrl string `json:"remote_url,omitempty"` + Progress string `json:"progress,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费 + TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费 +} + +func FailTaskInfo(reason string) *TaskInfo { + return &TaskInfo{ + Status: "FAILURE", + Reason: reason, + } +} + +// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段 +// service_tier: 服务层级字段,可能导致额外计费(OpenAI、Claude、Responses API 支持) +// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用) +// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私) +func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) ([]byte, error) { + var data map[string]interface{} + if err := common.Unmarshal(jsonData, &data); err != nil { + common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error()) + return jsonData, nil + } + + // 默认移除 service_tier,除非明确允许(避免额外计费风险) + if !channelOtherSettings.AllowServiceTier { + if _, exists := data["service_tier"]; exists { + delete(data, "service_tier") + } + } + + // 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用) + if channelOtherSettings.DisableStore { + if _, exists := data["store"]; exists { + delete(data, "store") + } + } + + // 默认移除 safety_identifier,除非明确允许(保护用户隐私,避免向 OpenAI 报告用户信息) + if !channelOtherSettings.AllowSafetyIdentifier { + if _, exists := data["safety_identifier"]; exists { + delete(data, "safety_identifier") + } + } + + jsonDataAfter, err := common.Marshal(data) + if err != nil { + common.SysError("RemoveDisabledFields Marshal error :" + err.Error()) + return jsonData, nil + } + return jsonDataAfter, nil +} diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go new file mode 100644 index 0000000000000000000000000000000000000000..1159298c472f89d1e742d68238b88778d06c63d0 --- /dev/null +++ b/relay/common/relay_utils.go @@ -0,0 +1,282 @@ +package common + +import ( + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + + "github.com/gin-gonic/gin" + "github.com/samber/lo" +) + +type HasPrompt interface { + GetPrompt() string +} + +type HasImage interface { + HasImage() bool +} + +func GetFullRequestURL(baseURL string, requestURL string, channelType int) string { + fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL) + + if strings.HasPrefix(baseURL, "https://gateway.ai.cloudflare.com") { + switch channelType { + case constant.ChannelTypeOpenAI: + fullRequestURL = fmt.Sprintf("%s%s", baseURL, strings.TrimPrefix(requestURL, "/v1")) + case constant.ChannelTypeAzure: + fullRequestURL = fmt.Sprintf("%s%s", baseURL, strings.TrimPrefix(requestURL, "/openai/deployments")) + } + } + return fullRequestURL +} + +func GetAPIVersion(c *gin.Context) string { + query := c.Request.URL.Query() + apiVersion := query.Get("api-version") + if apiVersion == "" { + apiVersion = c.GetString("api_version") + } + return apiVersion +} + +func createTaskError(err error, code string, statusCode int, localError bool) *dto.TaskError { + return &dto.TaskError{ + Code: code, + Message: err.Error(), + StatusCode: statusCode, + LocalError: localError, + Error: err, + } +} + +func storeTaskRequest(c *gin.Context, info *RelayInfo, action string, requestObj TaskSubmitReq) { + info.Action = action + c.Set("task_request", requestObj) +} +func GetTaskRequest(c *gin.Context) (TaskSubmitReq, error) { + v, exists := c.Get("task_request") + if !exists { + return TaskSubmitReq{}, fmt.Errorf("request not found in context") + } + req, ok := v.(TaskSubmitReq) + if !ok { + return TaskSubmitReq{}, fmt.Errorf("invalid task request type") + } + return req, nil +} + +func validatePrompt(prompt string) *dto.TaskError { + if strings.TrimSpace(prompt) == "" { + return createTaskError(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest, true) + } + return nil +} + +func validateMultipartTaskRequest(c *gin.Context, info *RelayInfo, action string) (TaskSubmitReq, error) { + var req TaskSubmitReq + if _, err := c.MultipartForm(); err != nil { + return req, err + } + + formData := c.Request.PostForm + req = TaskSubmitReq{ + Prompt: formData.Get("prompt"), + Model: formData.Get("model"), + Mode: formData.Get("mode"), + Image: formData.Get("image"), + Size: formData.Get("size"), + Metadata: make(map[string]interface{}), + } + + if durationStr := formData.Get("seconds"); durationStr != "" { + if duration, err := strconv.Atoi(durationStr); err == nil { + req.Duration = duration + } + } + + if images := formData["images"]; len(images) > 0 { + req.Images = images + } + + for key, values := range formData { + if len(values) > 0 && !isKnownTaskField(key) { + if intVal, err := strconv.Atoi(values[0]); err == nil { + req.Metadata[key] = intVal + } else if floatVal, err := strconv.ParseFloat(values[0], 64); err == nil { + req.Metadata[key] = floatVal + } else { + req.Metadata[key] = values[0] + } + } + } + return req, nil +} + +func ValidateMultipartDirect(c *gin.Context, info *RelayInfo) *dto.TaskError { + var prompt string + var model string + var seconds int + var size string + var hasInputReference bool + + var req TaskSubmitReq + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + return createTaskError(err, "invalid_json", http.StatusBadRequest, true) + } + + prompt = req.Prompt + model = req.Model + size = req.Size + seconds, _ = strconv.Atoi(req.Seconds) + if seconds == 0 { + seconds = req.Duration + } + if req.InputReference != "" { + req.Images = []string{req.InputReference} + } + + if strings.TrimSpace(req.Model) == "" { + return createTaskError(fmt.Errorf("model field is required"), "missing_model", http.StatusBadRequest, true) + } + + if req.HasImage() { + hasInputReference = true + } + + if taskErr := validatePrompt(prompt); taskErr != nil { + return taskErr + } + + action := constant.TaskActionTextGenerate + if hasInputReference { + action = constant.TaskActionGenerate + } + if strings.HasPrefix(model, "sora-2") { + + if size == "" { + size = "720x1280" + } + + if seconds <= 0 { + seconds = 4 + } + + if model == "sora-2" && !lo.Contains([]string{"720x1280", "1280x720"}, size) { + return createTaskError(fmt.Errorf("sora-2 size is invalid"), "invalid_size", http.StatusBadRequest, true) + } + if model == "sora-2-pro" && !lo.Contains([]string{"720x1280", "1280x720", "1792x1024", "1024x1792"}, size) { + return createTaskError(fmt.Errorf("sora-2 size is invalid"), "invalid_size", http.StatusBadRequest, true) + } + info.PriceData.OtherRatios = map[string]float64{ + "seconds": float64(seconds), + "size": 1, + } + if lo.Contains([]string{"1792x1024", "1024x1792"}, size) { + info.PriceData.OtherRatios["size"] = 1.666667 + } + } + + info.Action = action + + return nil +} + +func isKnownTaskField(field string) bool { + knownFields := map[string]bool{ + "prompt": true, + "model": true, + "mode": true, + "image": true, + "images": true, + "size": true, + "duration": true, + "input_reference": true, // Sora 特有字段 + } + return knownFields[field] +} + +func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *dto.TaskError { + var err error + contentType := c.GetHeader("Content-Type") + var req TaskSubmitReq + if strings.HasPrefix(contentType, "multipart/form-data") { + req, err = validateMultipartTaskRequest(c, info, action) + if err != nil { + return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true) + } + } else if err := common.UnmarshalBodyReusable(c, &req); err != nil { + return createTaskError(err, "invalid_request", http.StatusBadRequest, true) + } + + if taskErr := validatePrompt(req.Prompt); taskErr != nil { + return taskErr + } + + if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" { + // 兼容单图上传 + req.Images = []string{req.Image} + } + + storeTaskRequest(c, info, action, req) + return nil +} +func GetImagesBase64sFromForm(c *gin.Context) ([]*Base64Data, error) { + return GetBase64sFromForm(c, "image") +} +func GetImageBase64sFromForm(c *gin.Context) (*Base64Data, error) { + base64s, err := GetImagesBase64sFromForm(c) + if err != nil { + return nil, err + } + return base64s[0], nil +} + +type Base64Data struct { + MimeType string + Data string +} + +func (m Base64Data) String() string { + return fmt.Sprintf("data:%s;base64,%s", m.MimeType, m.Data) +} +func GetBase64sFromForm(c *gin.Context, fieldName string) ([]*Base64Data, error) { + mf := c.Request.MultipartForm + if mf == nil { + if _, err := c.MultipartForm(); err != nil { + return nil, fmt.Errorf("failed to parse image edit form request: %w", err) + } + mf = c.Request.MultipartForm + } + imageFiles, exists := mf.File[fieldName] + if !exists || len(imageFiles) == 0 { + return nil, errors.New("field " + fieldName + " is not found or empty") + } + var imageBase64s []*Base64Data + for _, file := range imageFiles { + image, err := file.Open() + if err != nil { + return nil, errors.New("failed to open image file") + } + defer image.Close() + imageData, err := io.ReadAll(image) + if err != nil { + return nil, errors.New("failed to read image file") + } + mimeType := http.DetectContentType(imageData) + base64Data := base64.StdEncoding.EncodeToString(imageData) + imageBase64s = append(imageBase64s, &Base64Data{ + MimeType: mimeType, + Data: base64Data, + }) + } + return imageBase64s, nil +} diff --git a/relay/common_handler/rerank.go b/relay/common_handler/rerank.go new file mode 100644 index 0000000000000000000000000000000000000000..daf005df4ebb7fd096e228cd9c97722469235fff --- /dev/null +++ b/relay/common_handler/rerank.go @@ -0,0 +1,75 @@ +package common_handler + +import ( + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel/xinference" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + if common.DebugEnabled { + println("reranker response body: ", string(responseBody)) + } + var jinaResp dto.RerankResponse + if info.ChannelType == constant.ChannelTypeXinference { + var xinRerankResponse xinference.XinRerankResponse + err = common.Unmarshal(responseBody, &xinRerankResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + jinaRespResults := make([]dto.RerankResponseResult, len(xinRerankResponse.Results)) + for i, result := range xinRerankResponse.Results { + respResult := dto.RerankResponseResult{ + Index: result.Index, + RelevanceScore: result.RelevanceScore, + } + if info.ReturnDocuments { + var document any + if result.Document != nil { + if doc, ok := result.Document.(string); ok { + if doc == "" { + document = info.Documents[result.Index] + } else { + document = doc + } + } else { + document = result.Document + } + } + respResult.Document = document + } + jinaRespResults[i] = respResult + } + jinaResp = dto.RerankResponse{ + Results: jinaRespResults, + Usage: dto.Usage{ + PromptTokens: info.PromptTokens, + TotalTokens: info.PromptTokens, + }, + } + } else { + err = common.Unmarshal(responseBody, &jinaResp) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + jinaResp.Usage.PromptTokens = jinaResp.Usage.TotalTokens + } + + c.Writer.Header().Set("Content-Type", "application/json") + c.JSON(http.StatusOK, jinaResp) + return &jinaResp.Usage, nil +} diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..cb3b5d5f2806f02ff1abda1c3c4f4548478bd6ac --- /dev/null +++ b/relay/compatible_handler.go @@ -0,0 +1,468 @@ +package relay + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/shopspring/decimal" + + "github.com/gin-gonic/gin" +) + +func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + textReq, ok := info.Request.(*dto.GeneralOpenAIRequest) + if !ok { + return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected dto.GeneralOpenAIRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(textReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to GeneralOpenAIRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + if request.WebSearchOptions != nil { + c.Set("chat_completion_web_search_context_size", request.WebSearchOptions.SearchContextSize) + } + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + includeUsage := true + // 判断用户是否需要返回使用情况 + if request.StreamOptions != nil { + includeUsage = request.StreamOptions.IncludeUsage + } + + // 如果不支持StreamOptions,将StreamOptions设置为nil + if !info.SupportStreamOptions || !request.Stream { + request.StreamOptions = nil + } else { + // 如果支持StreamOptions,且请求中没有设置StreamOptions,根据配置文件设置StreamOptions + if constant.ForceStreamOption { + request.StreamOptions = &dto.StreamOptions{ + IncludeUsage: true, + } + } + } + + info.ShouldIncludeUsage = includeUsage + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + var requestBody io.Reader + + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + if common.DebugEnabled { + println("requestBody: ", string(body)) + } + requestBody = bytes.NewBuffer(body) + } else { + convertedRequest, err := adaptor.ConvertOpenAIRequest(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + if info.ChannelSetting.SystemPrompt != "" { + // 如果有系统提示,则将其添加到请求中 + request, ok := convertedRequest.(*dto.GeneralOpenAIRequest) + if ok { + containSystemPrompt := false + for _, message := range request.Messages { + if message.Role == request.GetSystemRoleName() { + containSystemPrompt = true + break + } + } + if !containSystemPrompt { + // 如果没有系统提示,则添加系统提示 + systemMessage := dto.Message{ + Role: request.GetSystemRoleName(), + Content: info.ChannelSetting.SystemPrompt, + } + request.Messages = append([]dto.Message{systemMessage}, request.Messages...) + } else if info.ChannelSetting.SystemPromptOverride { + common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true) + // 如果有系统提示,且允许覆盖,则拼接到前面 + for i, message := range request.Messages { + if message.Role == request.GetSystemRoleName() { + if message.IsStringContent() { + request.Messages[i].SetStringContent(info.ChannelSetting.SystemPrompt + "\n" + message.StringContent()) + } else { + contents := message.ParseContent() + contents = append([]dto.MediaContent{ + { + Type: dto.ContentTypeText, + Text: info.ChannelSetting.SystemPrompt, + }, + }, contents...) + request.Messages[i].Content = contents + } + break + } + } + } + } + } + + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry()) + } + + // remove disabled fields for OpenAI API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + // apply param override + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + + logger.LogDebug(c, fmt.Sprintf("text request body: %s", string(jsonData))) + + requestBody = bytes.NewBuffer(jsonData) + } + + var httpResp *http.Response + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + + if resp != nil { + httpResp = resp.(*http.Response) + info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if httpResp.StatusCode != http.StatusOK { + newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newApiErr, statusCodeMappingStr) + return newApiErr + } + } + + usage, newApiErr := adaptor.DoResponse(c, httpResp, info) + if newApiErr != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newApiErr, statusCodeMappingStr) + return newApiErr + } + + if strings.HasPrefix(info.OriginModelName, "gpt-4o-audio") { + service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "") + } else { + postConsumeQuota(c, info, usage.(*dto.Usage), "") + } + return nil +} + +func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) { + if usage == nil { + usage = &dto.Usage{ + PromptTokens: relayInfo.PromptTokens, + CompletionTokens: 0, + TotalTokens: relayInfo.PromptTokens, + } + extraContent += "(可能是请求出错)" + } + useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() + promptTokens := usage.PromptTokens + cacheTokens := usage.PromptTokensDetails.CachedTokens + imageTokens := usage.PromptTokensDetails.ImageTokens + audioTokens := usage.PromptTokensDetails.AudioTokens + completionTokens := usage.CompletionTokens + cachedCreationTokens := usage.PromptTokensDetails.CachedCreationTokens + + modelName := relayInfo.OriginModelName + + tokenName := ctx.GetString("token_name") + completionRatio := relayInfo.PriceData.CompletionRatio + cacheRatio := relayInfo.PriceData.CacheRatio + imageRatio := relayInfo.PriceData.ImageRatio + modelRatio := relayInfo.PriceData.ModelRatio + groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio + modelPrice := relayInfo.PriceData.ModelPrice + cachedCreationRatio := relayInfo.PriceData.CacheCreationRatio + + // Convert values to decimal for precise calculation + dPromptTokens := decimal.NewFromInt(int64(promptTokens)) + dCacheTokens := decimal.NewFromInt(int64(cacheTokens)) + dImageTokens := decimal.NewFromInt(int64(imageTokens)) + dAudioTokens := decimal.NewFromInt(int64(audioTokens)) + dCompletionTokens := decimal.NewFromInt(int64(completionTokens)) + dCachedCreationTokens := decimal.NewFromInt(int64(cachedCreationTokens)) + dCompletionRatio := decimal.NewFromFloat(completionRatio) + dCacheRatio := decimal.NewFromFloat(cacheRatio) + dImageRatio := decimal.NewFromFloat(imageRatio) + dModelRatio := decimal.NewFromFloat(modelRatio) + dGroupRatio := decimal.NewFromFloat(groupRatio) + dModelPrice := decimal.NewFromFloat(modelPrice) + dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + + ratio := dModelRatio.Mul(dGroupRatio) + + // openai web search 工具计费 + var dWebSearchQuota decimal.Decimal + var webSearchPrice float64 + // response api 格式工具计费 + if relayInfo.ResponsesUsageInfo != nil { + if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool.CallCount > 0 { + // 计算 web search 调用的配额 (配额 = 价格 * 调用次数 / 1000 * 分组倍率) + webSearchPrice = operation_setting.GetWebSearchPricePerThousand(modelName, webSearchTool.SearchContextSize) + dWebSearchQuota = decimal.NewFromFloat(webSearchPrice). + Mul(decimal.NewFromInt(int64(webSearchTool.CallCount))). + Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit) + extraContent += fmt.Sprintf("Web Search 调用 %d 次,上下文大小 %s,调用花费 %s", + webSearchTool.CallCount, webSearchTool.SearchContextSize, dWebSearchQuota.String()) + } + } else if strings.HasSuffix(modelName, "search-preview") { + // search-preview 模型不支持 response api + searchContextSize := ctx.GetString("chat_completion_web_search_context_size") + if searchContextSize == "" { + searchContextSize = "medium" + } + webSearchPrice = operation_setting.GetWebSearchPricePerThousand(modelName, searchContextSize) + dWebSearchQuota = decimal.NewFromFloat(webSearchPrice). + Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit) + extraContent += fmt.Sprintf("Web Search 调用 1 次,上下文大小 %s,调用花费 %s", + searchContextSize, dWebSearchQuota.String()) + } + // claude web search tool 计费 + var dClaudeWebSearchQuota decimal.Decimal + var claudeWebSearchPrice float64 + claudeWebSearchCallCount := ctx.GetInt("claude_web_search_requests") + if claudeWebSearchCallCount > 0 { + claudeWebSearchPrice = operation_setting.GetClaudeWebSearchPricePerThousand() + dClaudeWebSearchQuota = decimal.NewFromFloat(claudeWebSearchPrice). + Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit).Mul(decimal.NewFromInt(int64(claudeWebSearchCallCount))) + extraContent += fmt.Sprintf("Claude Web Search 调用 %d 次,调用花费 %s", + claudeWebSearchCallCount, dClaudeWebSearchQuota.String()) + } + // file search tool 计费 + var dFileSearchQuota decimal.Decimal + var fileSearchPrice float64 + if relayInfo.ResponsesUsageInfo != nil { + if fileSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolFileSearch]; exists && fileSearchTool.CallCount > 0 { + fileSearchPrice = operation_setting.GetFileSearchPricePerThousand() + dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice). + Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))). + Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit) + extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 %s", + fileSearchTool.CallCount, dFileSearchQuota.String()) + } + } + var dImageGenerationCallQuota decimal.Decimal + var imageGenerationCallPrice float64 + if ctx.GetBool("image_generation_call") { + imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size")) + dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit) + extraContent += fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String()) + } + + var quotaCalculateDecimal decimal.Decimal + + var audioInputQuota decimal.Decimal + var audioInputPrice float64 + if !relayInfo.PriceData.UsePrice { + baseTokens := dPromptTokens + // 减去 cached tokens + var cachedTokensWithRatio decimal.Decimal + if !dCacheTokens.IsZero() { + baseTokens = baseTokens.Sub(dCacheTokens) + cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio) + } + var dCachedCreationTokensWithRatio decimal.Decimal + if !dCachedCreationTokens.IsZero() { + baseTokens = baseTokens.Sub(dCachedCreationTokens) + dCachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCachedCreationRatio) + } + + // 减去 image tokens + var imageTokensWithRatio decimal.Decimal + if !dImageTokens.IsZero() { + baseTokens = baseTokens.Sub(dImageTokens) + imageTokensWithRatio = dImageTokens.Mul(dImageRatio) + } + + // 减去 Gemini audio tokens + if !dAudioTokens.IsZero() { + audioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(modelName) + if audioInputPrice > 0 { + // 重新计算 base tokens + baseTokens = baseTokens.Sub(dAudioTokens) + audioInputQuota = decimal.NewFromFloat(audioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit) + extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String()) + } + } + promptQuota := baseTokens.Add(cachedTokensWithRatio). + Add(imageTokensWithRatio). + Add(dCachedCreationTokensWithRatio) + + completionQuota := dCompletionTokens.Mul(dCompletionRatio) + + quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio) + + if !ratio.IsZero() && quotaCalculateDecimal.LessThanOrEqual(decimal.Zero) { + quotaCalculateDecimal = decimal.NewFromInt(1) + } + } else { + quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio) + } + // 添加 responses tools call 调用的配额 + quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) + quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota) + // 添加 audio input 独立计费 + quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) + // 添加 image generation call 计费 + quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota) + + quota := int(quotaCalculateDecimal.Round(0).IntPart()) + totalTokens := promptTokens + completionTokens + + var logContent string + + // record all the consume log even if quota is 0 + if totalTokens == 0 { + // in this case, must be some error happened + // we cannot just return, because we may have to return the pre-consumed quota + quota = 0 + logContent += fmt.Sprintf("(可能是上游超时)") + logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+ + "tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota)) + } else { + if !ratio.IsZero() && quota == 0 { + quota = 1 + } + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota) + model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota) + } + + quotaDelta := quota - relayInfo.FinalPreConsumedQuota + + //logger.LogInfo(ctx, fmt.Sprintf("request quota delta: %s", logger.FormatQuota(quotaDelta))) + + if quotaDelta > 0 { + logger.LogInfo(ctx, fmt.Sprintf("预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)", + logger.FormatQuota(quotaDelta), + logger.FormatQuota(quota), + logger.FormatQuota(relayInfo.FinalPreConsumedQuota), + )) + } else if quotaDelta < 0 { + logger.LogInfo(ctx, fmt.Sprintf("预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)", + logger.FormatQuota(-quotaDelta), + logger.FormatQuota(quota), + logger.FormatQuota(relayInfo.FinalPreConsumedQuota), + )) + } + + if quotaDelta != 0 { + err := service.PostConsumeQuota(relayInfo, quotaDelta, relayInfo.FinalPreConsumedQuota, true) + if err != nil { + logger.LogError(ctx, "error consuming token remain quota: "+err.Error()) + } + } + + logModel := modelName + if strings.HasPrefix(logModel, "gpt-4-gizmo") { + logModel = "gpt-4-gizmo-*" + logContent += fmt.Sprintf(",模型 %s", modelName) + } + if strings.HasPrefix(logModel, "gpt-4o-gizmo") { + logModel = "gpt-4o-gizmo-*" + logContent += fmt.Sprintf(",模型 %s", modelName) + } + if extraContent != "" { + logContent += ", " + extraContent + } + other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio) + if imageTokens != 0 { + other["image"] = true + other["image_ratio"] = imageRatio + other["image_output"] = imageTokens + } + if cachedCreationTokens != 0 { + other["cache_creation_tokens"] = cachedCreationTokens + other["cache_creation_ratio"] = cachedCreationRatio + } + if !dWebSearchQuota.IsZero() { + if relayInfo.ResponsesUsageInfo != nil { + if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists { + other["web_search"] = true + other["web_search_call_count"] = webSearchTool.CallCount + other["web_search_price"] = webSearchPrice + } + } else if strings.HasSuffix(modelName, "search-preview") { + other["web_search"] = true + other["web_search_call_count"] = 1 + other["web_search_price"] = webSearchPrice + } + } else if !dClaudeWebSearchQuota.IsZero() { + other["web_search"] = true + other["web_search_call_count"] = claudeWebSearchCallCount + other["web_search_price"] = claudeWebSearchPrice + } + if !dFileSearchQuota.IsZero() && relayInfo.ResponsesUsageInfo != nil { + if fileSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolFileSearch]; exists { + other["file_search"] = true + other["file_search_call_count"] = fileSearchTool.CallCount + other["file_search_price"] = fileSearchPrice + } + } + if !audioInputQuota.IsZero() { + other["audio_input_seperate_price"] = true + other["audio_input_token_count"] = audioTokens + other["audio_input_price"] = audioInputPrice + } + if !dImageGenerationCallQuota.IsZero() { + other["image_generation_call"] = true + other["image_generation_call_price"] = imageGenerationCallPrice + } + model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ + ChannelId: relayInfo.ChannelId, + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + ModelName: logModel, + TokenName: tokenName, + Quota: quota, + Content: logContent, + TokenId: relayInfo.TokenId, + UseTimeSeconds: int(useTimeSeconds), + IsStream: relayInfo.IsStream, + Group: relayInfo.UsingGroup, + Other: other, + }) +} diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go new file mode 100644 index 0000000000000000000000000000000000000000..85a1b9c5f8821f112ad921a62d40b95f91c53b06 --- /dev/null +++ b/relay/constant/relay_mode.go @@ -0,0 +1,146 @@ +package constant + +import ( + "net/http" + "strings" +) + +const ( + RelayModeUnknown = iota + RelayModeChatCompletions + RelayModeCompletions + RelayModeEmbeddings + RelayModeModerations + RelayModeImagesGenerations + RelayModeImagesEdits + RelayModeEdits + + RelayModeMidjourneyImagine + RelayModeMidjourneyDescribe + RelayModeMidjourneyBlend + RelayModeMidjourneyChange + RelayModeMidjourneySimpleChange + RelayModeMidjourneyNotify + RelayModeMidjourneyTaskFetch + RelayModeMidjourneyTaskImageSeed + RelayModeMidjourneyTaskFetchByCondition + RelayModeMidjourneyAction + RelayModeMidjourneyModal + RelayModeMidjourneyShorten + RelayModeSwapFace + RelayModeMidjourneyUpload + RelayModeMidjourneyVideo + RelayModeMidjourneyEdits + + RelayModeAudioSpeech // tts + RelayModeAudioTranscription // whisper + RelayModeAudioTranslation // whisper + + RelayModeSunoFetch + RelayModeSunoFetchByID + RelayModeSunoSubmit + + RelayModeVideoFetchByID + RelayModeVideoSubmit + + RelayModeRerank + + RelayModeResponses + + RelayModeRealtime + + RelayModeGemini +) + +func Path2RelayMode(path string) int { + relayMode := RelayModeUnknown + if strings.HasPrefix(path, "/v1/chat/completions") || strings.HasPrefix(path, "/pg/chat/completions") { + relayMode = RelayModeChatCompletions + } else if strings.HasPrefix(path, "/v1/completions") { + relayMode = RelayModeCompletions + } else if strings.HasPrefix(path, "/v1/embeddings") { + relayMode = RelayModeEmbeddings + } else if strings.HasSuffix(path, "embeddings") { + relayMode = RelayModeEmbeddings + } else if strings.HasPrefix(path, "/v1/moderations") { + relayMode = RelayModeModerations + } else if strings.HasPrefix(path, "/v1/images/generations") { + relayMode = RelayModeImagesGenerations + } else if strings.HasPrefix(path, "/v1/images/edits") { + relayMode = RelayModeImagesEdits + } else if strings.HasPrefix(path, "/v1/edits") { + relayMode = RelayModeEdits + } else if strings.HasPrefix(path, "/v1/responses") { + relayMode = RelayModeResponses + } else if strings.HasPrefix(path, "/v1/audio/speech") { + relayMode = RelayModeAudioSpeech + } else if strings.HasPrefix(path, "/v1/audio/transcriptions") { + relayMode = RelayModeAudioTranscription + } else if strings.HasPrefix(path, "/v1/audio/translations") { + relayMode = RelayModeAudioTranslation + } else if strings.HasPrefix(path, "/v1/rerank") { + relayMode = RelayModeRerank + } else if strings.HasPrefix(path, "/v1/realtime") { + relayMode = RelayModeRealtime + } else if strings.HasPrefix(path, "/v1beta/models") || strings.HasPrefix(path, "/v1/models") { + relayMode = RelayModeGemini + } else if strings.HasPrefix(path, "/mj") { + relayMode = Path2RelayModeMidjourney(path) + } + return relayMode +} + +func Path2RelayModeMidjourney(path string) int { + relayMode := RelayModeUnknown + if strings.HasSuffix(path, "/mj/submit/action") { + // midjourney plus + relayMode = RelayModeMidjourneyAction + } else if strings.HasSuffix(path, "/mj/submit/modal") { + // midjourney plus + relayMode = RelayModeMidjourneyModal + } else if strings.HasSuffix(path, "/mj/submit/shorten") { + // midjourney plus + relayMode = RelayModeMidjourneyShorten + } else if strings.HasSuffix(path, "/mj/insight-face/swap") { + // midjourney plus + relayMode = RelayModeSwapFace + } else if strings.HasSuffix(path, "/submit/upload-discord-images") { + // midjourney plus + relayMode = RelayModeMidjourneyUpload + } else if strings.HasSuffix(path, "/mj/submit/imagine") { + relayMode = RelayModeMidjourneyImagine + } else if strings.HasSuffix(path, "/mj/submit/video") { + relayMode = RelayModeMidjourneyVideo + } else if strings.HasSuffix(path, "/mj/submit/edits") { + relayMode = RelayModeMidjourneyEdits + } else if strings.HasSuffix(path, "/mj/submit/blend") { + relayMode = RelayModeMidjourneyBlend + } else if strings.HasSuffix(path, "/mj/submit/describe") { + relayMode = RelayModeMidjourneyDescribe + } else if strings.HasSuffix(path, "/mj/notify") { + relayMode = RelayModeMidjourneyNotify + } else if strings.HasSuffix(path, "/mj/submit/change") { + relayMode = RelayModeMidjourneyChange + } else if strings.HasSuffix(path, "/mj/submit/simple-change") { + relayMode = RelayModeMidjourneyChange + } else if strings.HasSuffix(path, "/fetch") { + relayMode = RelayModeMidjourneyTaskFetch + } else if strings.HasSuffix(path, "/image-seed") { + relayMode = RelayModeMidjourneyTaskImageSeed + } else if strings.HasSuffix(path, "/list-by-condition") { + relayMode = RelayModeMidjourneyTaskFetchByCondition + } + return relayMode +} + +func Path2RelaySuno(method, path string) int { + relayMode := RelayModeUnknown + if method == http.MethodPost && strings.HasSuffix(path, "/fetch") { + relayMode = RelayModeSunoFetch + } else if method == http.MethodGet && strings.Contains(path, "/fetch/") { + relayMode = RelayModeSunoFetchByID + } else if strings.Contains(path, "/submit/") { + relayMode = RelayModeSunoSubmit + } + return relayMode +} diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..740ca400e7fd77f2ed09485de384210b658ac1f9 --- /dev/null +++ b/relay/embedding_handler.go @@ -0,0 +1,87 @@ +package relay + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + embeddingReq, ok := info.Request.(*dto.EmbeddingRequest) + if !ok { + return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected *dto.EmbeddingRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(embeddingReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to EmbeddingRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + convertedRequest, err := adaptor.ConvertEmbeddingRequest(c, info, *request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + jsonData, err := json.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + + logger.LogDebug(c, fmt.Sprintf("converted embedding request body: %s", string(jsonData))) + requestBody := bytes.NewBuffer(jsonData) + statusCodeMappingStr := c.GetString("status_code_mapping") + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + postConsumeQuota(c, info, usage.(*dto.Usage), "") + return nil +} diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..af13341bf0d4c8a1179135c757f3ab7334c54766 --- /dev/null +++ b/relay/gemini_handler.go @@ -0,0 +1,297 @@ +package relay + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/relay/channel/gemini" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func isNoThinkingRequest(req *dto.GeminiChatRequest) bool { + if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil { + configBudget := req.GenerationConfig.ThinkingConfig.ThinkingBudget + if configBudget != nil && *configBudget == 0 { + // 如果思考预算为 0,则认为是非思考请求 + return true + } + } + return false +} + +func trimModelThinking(modelName string) string { + // 去除模型名称中的 -nothinking 后缀 + if strings.HasSuffix(modelName, "-nothinking") { + return strings.TrimSuffix(modelName, "-nothinking") + } + // 去除模型名称中的 -thinking 后缀 + if strings.HasSuffix(modelName, "-thinking") { + return strings.TrimSuffix(modelName, "-thinking") + } + + // 去除模型名称中的 -thinking-number + if strings.Contains(modelName, "-thinking-") { + parts := strings.Split(modelName, "-thinking-") + if len(parts) > 1 { + return parts[0] + "-thinking" + } + } + return modelName +} + +func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + geminiReq, ok := info.Request.(*dto.GeminiChatRequest) + if !ok { + return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected *dto.GeminiChatRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(geminiReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to GeminiChatRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + // model mapped 模型映射 + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { + if isNoThinkingRequest(request) { + // check is thinking + if !strings.Contains(info.OriginModelName, "-nothinking") { + // try to get no thinking model price + noThinkingModelName := info.OriginModelName + "-nothinking" + containPrice := helper.ContainPriceOrRatio(noThinkingModelName) + if containPrice { + info.OriginModelName = noThinkingModelName + info.UpstreamModelName = noThinkingModelName + } + } + } + if request.GenerationConfig.ThinkingConfig == nil { + gemini.ThinkingAdaptor(request, info) + } + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + + adaptor.Init(info) + + if info.ChannelSetting.SystemPrompt != "" { + if request.SystemInstructions == nil { + request.SystemInstructions = &dto.GeminiChatContent{ + Parts: []dto.GeminiPart{ + {Text: info.ChannelSetting.SystemPrompt}, + }, + } + } else if len(request.SystemInstructions.Parts) == 0 { + request.SystemInstructions.Parts = []dto.GeminiPart{{Text: info.ChannelSetting.SystemPrompt}} + } else if info.ChannelSetting.SystemPromptOverride { + common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true) + merged := false + for i := range request.SystemInstructions.Parts { + if request.SystemInstructions.Parts[i].Text == "" { + continue + } + request.SystemInstructions.Parts[i].Text = info.ChannelSetting.SystemPrompt + "\n" + request.SystemInstructions.Parts[i].Text + merged = true + break + } + if !merged { + request.SystemInstructions.Parts = append([]dto.GeminiPart{{Text: info.ChannelSetting.SystemPrompt}}, request.SystemInstructions.Parts...) + } + } + } + + // Clean up empty system instruction + if request.SystemInstructions != nil { + hasContent := false + for _, part := range request.SystemInstructions.Parts { + if part.Text != "" { + hasContent = true + break + } + } + if !hasContent { + request.SystemInstructions = nil + } + } + + var requestBody io.Reader + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + requestBody = bytes.NewReader(body) + } else { + // 使用 ConvertGeminiRequest 转换请求格式 + convertedRequest, err := adaptor.ConvertGeminiRequest(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + // apply param override + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + + logger.LogDebug(c, "Gemini request body: "+string(jsonData)) + + requestBody = bytes.NewReader(jsonData) + } + + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + logger.LogError(c, "Do gemini request failed: "+err.Error()) + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, openaiErr := adaptor.DoResponse(c, resp.(*http.Response), info) + if openaiErr != nil { + service.ResetStatusCode(openaiErr, statusCodeMappingStr) + return openaiErr + } + + postConsumeQuota(c, info, usage.(*dto.Usage), "") + return nil +} + +func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + isBatch := strings.HasSuffix(c.Request.URL.Path, "batchEmbedContents") + info.IsGeminiBatchEmbedding = isBatch + + var req dto.Request + var err error + var inputTexts []string + + if isBatch { + batchRequest := &dto.GeminiBatchEmbeddingRequest{} + err = common.UnmarshalBodyReusable(c, batchRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + req = batchRequest + for _, r := range batchRequest.Requests { + for _, part := range r.Content.Parts { + if part.Text != "" { + inputTexts = append(inputTexts, part.Text) + } + } + } + } else { + singleRequest := &dto.GeminiEmbeddingRequest{} + err = common.UnmarshalBodyReusable(c, singleRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + req = singleRequest + for _, part := range singleRequest.Content.Parts { + if part.Text != "" { + inputTexts = append(inputTexts, part.Text) + } + } + } + + err = helper.ModelMappedHelper(c, info, req) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + req.SetModelName("models/" + info.UpstreamModelName) + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + var requestBody io.Reader + jsonData, err := common.Marshal(req) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + // apply param override + if len(info.ParamOverride) > 0 { + reqMap := make(map[string]interface{}) + _ = common.Unmarshal(jsonData, &reqMap) + for key, value := range info.ParamOverride { + reqMap[key] = value + } + jsonData, err = common.Marshal(reqMap) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + logger.LogDebug(c, "Gemini embedding request body: "+string(jsonData)) + requestBody = bytes.NewReader(jsonData) + + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + logger.LogError(c, "Do gemini request failed: "+err.Error()) + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, openaiErr := adaptor.DoResponse(c, resp.(*http.Response), info) + if openaiErr != nil { + service.ResetStatusCode(openaiErr, statusCodeMappingStr) + return openaiErr + } + + postConsumeQuota(c, info, usage.(*dto.Usage), "") + return nil +} diff --git a/relay/helper/common.go b/relay/helper/common.go new file mode 100644 index 0000000000000000000000000000000000000000..3bb1c80c910811da342d50911031a25822ea0726 --- /dev/null +++ b/relay/helper/common.go @@ -0,0 +1,184 @@ +package helper + +import ( + "errors" + "fmt" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func FlushWriter(c *gin.Context) error { + if c.Writer == nil { + return nil + } + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + return nil + } + return errors.New("streaming error: flusher not found") +} + +func SetEventStreamHeaders(c *gin.Context) { + // 检查是否已经设置过头部 + if _, exists := c.Get("event_stream_headers_set"); exists { + return + } + + // 设置标志,表示头部已经设置过 + c.Set("event_stream_headers_set", true) + + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("Transfer-Encoding", "chunked") + c.Writer.Header().Set("X-Accel-Buffering", "no") +} + +func ClaudeData(c *gin.Context, resp dto.ClaudeResponse) error { + jsonData, err := common.Marshal(resp) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + } else { + c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)}) + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonData)}) + } + _ = FlushWriter(c) + return nil +} + +func ClaudeChunkData(c *gin.Context, resp dto.ClaudeResponse, data string) { + c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)}) + c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("data: %s\n", data)}) + _ = FlushWriter(c) +} + +func ResponseChunkData(c *gin.Context, resp dto.ResponsesStreamResponse, data string) { + c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)}) + c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("data: %s", data)}) + _ = FlushWriter(c) +} + +func StringData(c *gin.Context, str string) error { + //str = strings.TrimPrefix(str, "data: ") + //str = strings.TrimSuffix(str, "\r") + c.Render(-1, common.CustomEvent{Data: "data: " + str}) + _ = FlushWriter(c) + return nil +} + +func PingData(c *gin.Context) error { + c.Writer.Write([]byte(": PING\n\n")) + _ = FlushWriter(c) + return nil +} + +func ObjectData(c *gin.Context, object interface{}) error { + if object == nil { + return errors.New("object is nil") + } + jsonData, err := common.Marshal(object) + if err != nil { + return fmt.Errorf("error marshalling object: %w", err) + } + return StringData(c, string(jsonData)) +} + +func Done(c *gin.Context) { + _ = StringData(c, "[DONE]") +} + +func WssString(c *gin.Context, ws *websocket.Conn, str string) error { + if ws == nil { + logger.LogError(c, "websocket connection is nil") + return errors.New("websocket connection is nil") + } + //common.LogInfo(c, fmt.Sprintf("sending message: %s", str)) + return ws.WriteMessage(1, []byte(str)) +} + +func WssObject(c *gin.Context, ws *websocket.Conn, object interface{}) error { + jsonData, err := common.Marshal(object) + if err != nil { + return fmt.Errorf("error marshalling object: %w", err) + } + if ws == nil { + logger.LogError(c, "websocket connection is nil") + return errors.New("websocket connection is nil") + } + //common.LogInfo(c, fmt.Sprintf("sending message: %s", jsonData)) + return ws.WriteMessage(1, jsonData) +} + +func WssError(c *gin.Context, ws *websocket.Conn, openaiError types.OpenAIError) { + if ws == nil { + return + } + errorObj := &dto.RealtimeEvent{ + Type: "error", + EventId: GetLocalRealtimeID(c), + Error: &openaiError, + } + _ = WssObject(c, ws, errorObj) +} + +func GetResponseID(c *gin.Context) string { + logID := c.GetString(common.RequestIdKey) + return fmt.Sprintf("chatcmpl-%s", logID) +} + +func GetLocalRealtimeID(c *gin.Context) string { + logID := c.GetString(common.RequestIdKey) + return fmt.Sprintf("evt_%s", logID) +} + +func GenerateStartEmptyResponse(id string, createAt int64, model string, systemFingerprint *string) *dto.ChatCompletionsStreamResponse { + return &dto.ChatCompletionsStreamResponse{ + Id: id, + Object: "chat.completion.chunk", + Created: createAt, + Model: model, + SystemFingerprint: systemFingerprint, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Role: "assistant", + Content: common.GetPointer(""), + }, + }, + }, + } +} + +func GenerateStopResponse(id string, createAt int64, model string, finishReason string) *dto.ChatCompletionsStreamResponse { + return &dto.ChatCompletionsStreamResponse{ + Id: id, + Object: "chat.completion.chunk", + Created: createAt, + Model: model, + SystemFingerprint: nil, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + FinishReason: &finishReason, + }, + }, + } +} + +func GenerateFinalUsageResponse(id string, createAt int64, model string, usage dto.Usage) *dto.ChatCompletionsStreamResponse { + return &dto.ChatCompletionsStreamResponse{ + Id: id, + Object: "chat.completion.chunk", + Created: createAt, + Model: model, + SystemFingerprint: nil, + Choices: make([]dto.ChatCompletionsStreamResponseChoice, 0), + Usage: &usage, + } +} diff --git a/relay/helper/model_mapped.go b/relay/helper/model_mapped.go new file mode 100644 index 0000000000000000000000000000000000000000..821158faedf40f1b7d98caa8afd987c03e1cb80a --- /dev/null +++ b/relay/helper/model_mapped.go @@ -0,0 +1,58 @@ +package helper + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/common" + "github.com/gin-gonic/gin" +) + +func ModelMappedHelper(c *gin.Context, info *common.RelayInfo, request dto.Request) error { + // map model name + modelMapping := c.GetString("model_mapping") + if modelMapping != "" && modelMapping != "{}" { + modelMap := make(map[string]string) + err := json.Unmarshal([]byte(modelMapping), &modelMap) + if err != nil { + return fmt.Errorf("unmarshal_model_mapping_failed") + } + + // 支持链式模型重定向,最终使用链尾的模型 + currentModel := info.OriginModelName + visitedModels := map[string]bool{ + currentModel: true, + } + for { + if mappedModel, exists := modelMap[currentModel]; exists && mappedModel != "" { + // 模型重定向循环检测,避免无限循环 + if visitedModels[mappedModel] { + if mappedModel == currentModel { + if currentModel == info.OriginModelName { + info.IsModelMapped = false + return nil + } else { + info.IsModelMapped = true + break + } + } + return errors.New("model_mapping_contains_cycle") + } + visitedModels[mappedModel] = true + currentModel = mappedModel + info.IsModelMapped = true + } else { + break + } + } + if info.IsModelMapped { + info.UpstreamModelName = currentModel + } + } + if request != nil { + request.SetModelName(info.UpstreamModelName) + } + return nil +} diff --git a/relay/helper/price.go b/relay/helper/price.go new file mode 100644 index 0000000000000000000000000000000000000000..2a774e405d1455f65ab5e1360ef71355984c125a --- /dev/null +++ b/relay/helper/price.go @@ -0,0 +1,172 @@ +package helper + +import ( + "fmt" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +// https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration +const claudeCacheCreation1hMultiplier = 6 / 3.75 + +// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present +func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) types.GroupRatioInfo { + groupRatioInfo := types.GroupRatioInfo{ + GroupRatio: 1.0, // default ratio + GroupSpecialRatio: -1, + } + + // check auto group + autoGroup, exists := ctx.Get("auto_group") + if exists { + logger.LogDebug(ctx, fmt.Sprintf("final group: %s", autoGroup)) + relayInfo.UsingGroup = autoGroup.(string) + } + + // check user group special ratio + userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup) + if ok { + // user group special ratio + groupRatioInfo.GroupSpecialRatio = userGroupRatio + groupRatioInfo.GroupRatio = userGroupRatio + groupRatioInfo.HasSpecialRatio = true + } else { + // normal group ratio + groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.UsingGroup) + } + + return groupRatioInfo +} + +func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta) (types.PriceData, error) { + modelPrice, usePrice := ratio_setting.GetModelPrice(info.OriginModelName, false) + + groupRatioInfo := HandleGroupRatio(c, info) + + var preConsumedQuota int + var modelRatio float64 + var completionRatio float64 + var cacheRatio float64 + var imageRatio float64 + var cacheCreationRatio float64 + var cacheCreationRatio5m float64 + var cacheCreationRatio1h float64 + var audioRatio float64 + var audioCompletionRatio float64 + var freeModel bool + if !usePrice { + preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota) + if meta.MaxTokens != 0 { + preConsumedTokens += meta.MaxTokens + } + var success bool + var matchName string + modelRatio, success, matchName = ratio_setting.GetModelRatio(info.OriginModelName) + if !success { + acceptUnsetRatio := false + if info.UserSetting.AcceptUnsetRatioModel { + acceptUnsetRatio = true + } + if !acceptUnsetRatio { + return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName) + } + } + completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName) + cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName) + cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName) + cacheCreationRatio5m = cacheCreationRatio + // 固定1h和5min缓存写入价格的比例 + cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier + imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName) + audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName) + audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName) + ratio := modelRatio * groupRatioInfo.GroupRatio + preConsumedQuota = int(float64(preConsumedTokens) * ratio) + } else { + if meta.ImagePriceRatio != 0 { + modelPrice = modelPrice * meta.ImagePriceRatio + } + preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + } + + // check if free model pre-consume is disabled + if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { + // if model price or ratio is 0, do not pre-consume quota + if usePrice { + if modelPrice == 0 { + preConsumedQuota = 0 + freeModel = true + } + } else { + if modelRatio == 0 { + preConsumedQuota = 0 + freeModel = true + } + } + } + + priceData := types.PriceData{ + FreeModel: freeModel, + ModelPrice: modelPrice, + ModelRatio: modelRatio, + CompletionRatio: completionRatio, + GroupRatioInfo: groupRatioInfo, + UsePrice: usePrice, + CacheRatio: cacheRatio, + ImageRatio: imageRatio, + AudioRatio: audioRatio, + AudioCompletionRatio: audioCompletionRatio, + CacheCreationRatio: cacheCreationRatio, + CacheCreation5mRatio: cacheCreationRatio5m, + CacheCreation1hRatio: cacheCreationRatio1h, + QuotaToPreConsume: preConsumedQuota, + } + + if common.DebugEnabled { + println(fmt.Sprintf("model_price_helper result: %s", priceData.ToSetting())) + } + info.PriceData = priceData + return priceData, nil +} + +// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task) +func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types.PerCallPriceData { + groupRatioInfo := HandleGroupRatio(c, info) + + modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true) + // 如果没有配置价格,则使用默认价格 + if !success { + defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName] + if !ok { + modelPrice = 0.1 + } else { + modelPrice = defaultPrice + } + } + quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + priceData := types.PerCallPriceData{ + ModelPrice: modelPrice, + Quota: quota, + GroupRatioInfo: groupRatioInfo, + } + return priceData +} + +func ContainPriceOrRatio(modelName string) bool { + _, ok := ratio_setting.GetModelPrice(modelName, false) + if ok { + return true + } + _, ok, _ = ratio_setting.GetModelRatio(modelName) + if ok { + return true + } + return false +} diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go new file mode 100644 index 0000000000000000000000000000000000000000..9044a16509b6b2f7e7d1a168eaf9dd885cb18670 --- /dev/null +++ b/relay/helper/stream_scanner.go @@ -0,0 +1,263 @@ +package helper + +import ( + "bufio" + "context" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/operation_setting" + + "github.com/bytedance/gopkg/util/gopool" + + "github.com/gin-gonic/gin" +) + +const ( + InitialScannerBufferSize = 64 << 10 // 64KB (64*1024) + MaxScannerBufferSize = 10 << 20 // 10MB (10*1024*1024) + DefaultPingInterval = 10 * time.Second +) + +func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, dataHandler func(data string) bool) { + + if resp == nil || dataHandler == nil { + return + } + + // 确保响应体总是被关闭 + defer func() { + if resp.Body != nil { + resp.Body.Close() + } + }() + + streamingTimeout := time.Duration(constant.StreamingTimeout) * time.Second + + var ( + stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞 + scanner = bufio.NewScanner(resp.Body) + ticker = time.NewTicker(streamingTimeout) + pingTicker *time.Ticker + writeMutex sync.Mutex // Mutex to protect concurrent writes + wg sync.WaitGroup // 用于等待所有 goroutine 退出 + ) + + generalSettings := operation_setting.GetGeneralSetting() + pingEnabled := generalSettings.PingIntervalEnabled && !info.DisablePing + pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second + if pingInterval <= 0 { + pingInterval = DefaultPingInterval + } + + if pingEnabled { + pingTicker = time.NewTicker(pingInterval) + } + + if common.DebugEnabled { + // print timeout and ping interval for debugging + println("relay timeout seconds:", common.RelayTimeout) + println("streaming timeout seconds:", int64(streamingTimeout.Seconds())) + println("ping interval seconds:", int64(pingInterval.Seconds())) + } + + // 改进资源清理,确保所有 goroutine 正确退出 + defer func() { + // 通知所有 goroutine 停止 + common.SafeSendBool(stopChan, true) + + ticker.Stop() + if pingTicker != nil { + pingTicker.Stop() + } + + // 等待所有 goroutine 退出,最多等待5秒 + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + logger.LogError(c, "timeout waiting for goroutines to exit") + } + + close(stopChan) + }() + + scanner.Buffer(make([]byte, InitialScannerBufferSize), MaxScannerBufferSize) + scanner.Split(bufio.ScanLines) + SetEventStreamHeaders(c) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ctx = context.WithValue(ctx, "stop_chan", stopChan) + + // Handle ping data sending with improved error handling + if pingEnabled && pingTicker != nil { + wg.Add(1) + gopool.Go(func() { + defer func() { + wg.Done() + if r := recover(); r != nil { + logger.LogError(c, fmt.Sprintf("ping goroutine panic: %v", r)) + common.SafeSendBool(stopChan, true) + } + if common.DebugEnabled { + println("ping goroutine exited") + } + }() + + // 添加超时保护,防止 goroutine 无限运行 + maxPingDuration := 30 * time.Minute // 最大 ping 持续时间 + pingTimeout := time.NewTimer(maxPingDuration) + defer pingTimeout.Stop() + + for { + select { + case <-pingTicker.C: + // 使用超时机制防止写操作阻塞 + done := make(chan error, 1) + go func() { + writeMutex.Lock() + defer writeMutex.Unlock() + done <- PingData(c) + }() + + select { + case err := <-done: + if err != nil { + logger.LogError(c, "ping data error: "+err.Error()) + return + } + if common.DebugEnabled { + println("ping data sent") + } + case <-time.After(10 * time.Second): + logger.LogError(c, "ping data send timeout") + return + case <-ctx.Done(): + return + case <-stopChan: + return + } + case <-ctx.Done(): + return + case <-stopChan: + return + case <-c.Request.Context().Done(): + // 监听客户端断开连接 + return + case <-pingTimeout.C: + logger.LogError(c, "ping goroutine max duration reached") + return + } + } + }) + } + + // Scanner goroutine with improved error handling + wg.Add(1) + common.RelayCtxGo(ctx, func() { + defer func() { + wg.Done() + if r := recover(); r != nil { + logger.LogError(c, fmt.Sprintf("scanner goroutine panic: %v", r)) + } + common.SafeSendBool(stopChan, true) + if common.DebugEnabled { + println("scanner goroutine exited") + } + }() + + for scanner.Scan() { + // 检查是否需要停止 + select { + case <-stopChan: + return + case <-ctx.Done(): + return + case <-c.Request.Context().Done(): + return + default: + } + + ticker.Reset(streamingTimeout) + data := scanner.Text() + if common.DebugEnabled { + println(data) + } + + if len(data) < 6 { + continue + } + if data[:5] != "data:" && data[:6] != "[DONE]" { + continue + } + data = data[5:] + data = strings.TrimLeft(data, " ") + data = strings.TrimSuffix(data, "\r") + if !strings.HasPrefix(data, "[DONE]") { + info.SetFirstResponseTime() + + // 使用超时机制防止写操作阻塞 + done := make(chan bool, 1) + go func() { + writeMutex.Lock() + defer writeMutex.Unlock() + done <- dataHandler(data) + }() + + select { + case success := <-done: + if !success { + return + } + case <-time.After(10 * time.Second): + logger.LogError(c, "data handler timeout") + return + case <-ctx.Done(): + return + case <-stopChan: + return + } + } else { + // done, 处理完成标志,直接退出停止读取剩余数据防止出错 + if common.DebugEnabled { + println("received [DONE], stopping scanner") + } + return + } + } + + if err := scanner.Err(); err != nil { + if err != io.EOF { + logger.LogError(c, "scanner error: "+err.Error()) + } + } + }) + + // 主循环等待完成或超时 + select { + case <-ticker.C: + // 超时处理逻辑 + logger.LogError(c, "streaming timeout") + case <-stopChan: + // 正常结束 + logger.LogInfo(c, "streaming finished") + case <-c.Request.Context().Done(): + // 客户端断开连接 + logger.LogInfo(c, "client disconnected") + } +} diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go new file mode 100644 index 0000000000000000000000000000000000000000..e6e8dc989e404d85f4cc533c87268ee8789aa6b8 --- /dev/null +++ b/relay/helper/valid_request.go @@ -0,0 +1,328 @@ +package helper + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dto.Request, err error) { + relayMode := relayconstant.Path2RelayMode(c.Request.URL.Path) + + switch format { + case types.RelayFormatOpenAI: + request, err = GetAndValidateTextRequest(c, relayMode) + case types.RelayFormatGemini: + if strings.Contains(c.Request.URL.Path, ":embedContent") { + request, err = GetAndValidateGeminiEmbeddingRequest(c) + } else if strings.Contains(c.Request.URL.Path, ":batchEmbedContents") { + request, err = GetAndValidateGeminiBatchEmbeddingRequest(c) + } else { + request, err = GetAndValidateGeminiRequest(c) + } + case types.RelayFormatClaude: + request, err = GetAndValidateClaudeRequest(c) + case types.RelayFormatOpenAIResponses: + request, err = GetAndValidateResponsesRequest(c) + + case types.RelayFormatOpenAIImage: + request, err = GetAndValidOpenAIImageRequest(c, relayMode) + case types.RelayFormatEmbedding: + request, err = GetAndValidateEmbeddingRequest(c, relayMode) + case types.RelayFormatRerank: + request, err = GetAndValidateRerankRequest(c) + case types.RelayFormatOpenAIAudio: + request, err = GetAndValidAudioRequest(c, relayMode) + case types.RelayFormatOpenAIRealtime: + request = &dto.BaseRequest{} + default: + return nil, fmt.Errorf("unsupported relay format: %s", format) + } + return request, err +} + +func GetAndValidAudioRequest(c *gin.Context, relayMode int) (*dto.AudioRequest, error) { + audioRequest := &dto.AudioRequest{} + err := common.UnmarshalBodyReusable(c, audioRequest) + if err != nil { + return nil, err + } + switch relayMode { + case relayconstant.RelayModeAudioSpeech: + if audioRequest.Model == "" { + return nil, errors.New("model is required") + } + default: + if audioRequest.Model == "" { + return nil, errors.New("model is required") + } + if audioRequest.ResponseFormat == "" { + audioRequest.ResponseFormat = "json" + } + } + return audioRequest, nil +} + +func GetAndValidateRerankRequest(c *gin.Context) (*dto.RerankRequest, error) { + var rerankRequest *dto.RerankRequest + err := common.UnmarshalBodyReusable(c, &rerankRequest) + if err != nil { + logger.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error())) + return nil, types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + if rerankRequest.Query == "" { + return nil, types.NewError(fmt.Errorf("query is empty"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + if len(rerankRequest.Documents) == 0 { + return nil, types.NewError(fmt.Errorf("documents is empty"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + return rerankRequest, nil +} + +func GetAndValidateEmbeddingRequest(c *gin.Context, relayMode int) (*dto.EmbeddingRequest, error) { + var embeddingRequest *dto.EmbeddingRequest + err := common.UnmarshalBodyReusable(c, &embeddingRequest) + if err != nil { + logger.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error())) + return nil, types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + if embeddingRequest.Input == nil { + return nil, fmt.Errorf("input is empty") + } + if relayMode == relayconstant.RelayModeModerations && embeddingRequest.Model == "" { + embeddingRequest.Model = "omni-moderation-latest" + } + if relayMode == relayconstant.RelayModeEmbeddings && embeddingRequest.Model == "" { + embeddingRequest.Model = c.Param("model") + } + return embeddingRequest, nil +} + +func GetAndValidateResponsesRequest(c *gin.Context) (*dto.OpenAIResponsesRequest, error) { + request := &dto.OpenAIResponsesRequest{} + err := common.UnmarshalBodyReusable(c, request) + if err != nil { + return nil, err + } + if request.Model == "" { + return nil, errors.New("model is required") + } + if request.Input == nil { + return nil, errors.New("input is required") + } + return request, nil +} + +func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageRequest, error) { + imageRequest := &dto.ImageRequest{} + + switch relayMode { + case relayconstant.RelayModeImagesEdits: + if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { + _, err := c.MultipartForm() + if err != nil { + return nil, fmt.Errorf("failed to parse image edit form request: %w", err) + } + formData := c.Request.PostForm + imageRequest.Prompt = formData.Get("prompt") + imageRequest.Model = formData.Get("model") + imageRequest.N = uint(common.String2Int(formData.Get("n"))) + imageRequest.Quality = formData.Get("quality") + imageRequest.Size = formData.Get("size") + imageRequest.ResponseFormat = formData.Get("response_format") + if imageValue := formData.Get("image"); imageValue != "" { + imageRequest.Image, _ = json.Marshal(imageValue) + } + + if imageRequest.Model == "gpt-image-1" { + if imageRequest.Quality == "" { + imageRequest.Quality = "standard" + } + } + if imageRequest.N == 0 { + imageRequest.N = 1 + } + + hasWatermark := formData.Has("watermark") + if hasWatermark { + watermark := formData.Get("watermark") == "true" + imageRequest.Watermark = &watermark + } + break + } + fallthrough + default: + err := common.UnmarshalBodyReusable(c, imageRequest) + if err != nil { + return nil, err + } + + if imageRequest.Model == "" { + //imageRequest.Model = "dall-e-3" + return nil, errors.New("model is required") + } + + if strings.Contains(imageRequest.Size, "×") { + return nil, errors.New("size an unexpected error occurred in the parameter, please use 'x' instead of the multiplication sign '×'") + } + + // Not "256x256", "512x512", or "1024x1024" + if imageRequest.Model == "dall-e-2" || imageRequest.Model == "dall-e" { + if imageRequest.Size != "" && imageRequest.Size != "256x256" && imageRequest.Size != "512x512" && imageRequest.Size != "1024x1024" { + return nil, errors.New("size must be one of 256x256, 512x512, or 1024x1024 for dall-e-2 or dall-e") + } + if imageRequest.Size == "" { + imageRequest.Size = "1024x1024" + } + } else if imageRequest.Model == "dall-e-3" { + if imageRequest.Size != "" && imageRequest.Size != "1024x1024" && imageRequest.Size != "1024x1792" && imageRequest.Size != "1792x1024" { + return nil, errors.New("size must be one of 1024x1024, 1024x1792 or 1792x1024 for dall-e-3") + } + if imageRequest.Quality == "" { + imageRequest.Quality = "standard" + } + if imageRequest.Size == "" { + imageRequest.Size = "1024x1024" + } + } else if imageRequest.Model == "gpt-image-1" { + if imageRequest.Quality == "" { + imageRequest.Quality = "auto" + } + } + + //if imageRequest.Prompt == "" { + // return nil, errors.New("prompt is required") + //} + + if imageRequest.N == 0 { + imageRequest.N = 1 + } + } + + return imageRequest, nil +} + +func GetAndValidateClaudeRequest(c *gin.Context) (textRequest *dto.ClaudeRequest, err error) { + textRequest = &dto.ClaudeRequest{} + err = c.ShouldBindJSON(textRequest) + if err != nil { + return nil, err + } + if textRequest.Messages == nil || len(textRequest.Messages) == 0 { + return nil, errors.New("field messages is required") + } + if textRequest.Model == "" { + return nil, errors.New("field model is required") + } + + //if textRequest.Stream { + // relayInfo.IsStream = true + //} + + return textRequest, nil +} + +func GetAndValidateTextRequest(c *gin.Context, relayMode int) (*dto.GeneralOpenAIRequest, error) { + textRequest := &dto.GeneralOpenAIRequest{} + err := common.UnmarshalBodyReusable(c, textRequest) + if err != nil { + return nil, err + } + + if relayMode == relayconstant.RelayModeModerations && textRequest.Model == "" { + textRequest.Model = "text-moderation-latest" + } + if relayMode == relayconstant.RelayModeEmbeddings && textRequest.Model == "" { + textRequest.Model = c.Param("model") + } + + if textRequest.MaxTokens > math.MaxInt32/2 { + return nil, errors.New("max_tokens is invalid") + } + if textRequest.Model == "" { + return nil, errors.New("model is required") + } + if textRequest.WebSearchOptions != nil { + if textRequest.WebSearchOptions.SearchContextSize != "" { + validSizes := map[string]bool{ + "high": true, + "medium": true, + "low": true, + } + if !validSizes[textRequest.WebSearchOptions.SearchContextSize] { + return nil, errors.New("invalid search_context_size, must be one of: high, medium, low") + } + } else { + textRequest.WebSearchOptions.SearchContextSize = "medium" + } + } + switch relayMode { + case relayconstant.RelayModeCompletions: + if textRequest.Prompt == "" { + return nil, errors.New("field prompt is required") + } + case relayconstant.RelayModeChatCompletions: + // For FIM (Fill-in-the-middle) requests with prefix/suffix, messages is optional + // It will be filled by provider-specific adaptors if needed (e.g., SiliconFlow)。Or it is allowed by model vendor(s) (e.g., DeepSeek) + if len(textRequest.Messages) == 0 && textRequest.Prefix == nil && textRequest.Suffix == nil { + return nil, errors.New("field messages is required") + } + case relayconstant.RelayModeEmbeddings: + case relayconstant.RelayModeModerations: + if textRequest.Input == nil || textRequest.Input == "" { + return nil, errors.New("field input is required") + } + case relayconstant.RelayModeEdits: + if textRequest.Instruction == "" { + return nil, errors.New("field instruction is required") + } + } + return textRequest, nil +} + +func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error) { + request := &dto.GeminiChatRequest{} + err := common.UnmarshalBodyReusable(c, request) + if err != nil { + return nil, err + } + if len(request.Contents) == 0 && len(request.Requests) == 0 { + return nil, errors.New("contents is required") + } + + //if c.Query("alt") == "sse" { + // relayInfo.IsStream = true + //} + + return request, nil +} + +func GetAndValidateGeminiEmbeddingRequest(c *gin.Context) (*dto.GeminiEmbeddingRequest, error) { + request := &dto.GeminiEmbeddingRequest{} + err := common.UnmarshalBodyReusable(c, request) + if err != nil { + return nil, err + } + return request, nil +} + +func GetAndValidateGeminiBatchEmbeddingRequest(c *gin.Context) (*dto.GeminiBatchEmbeddingRequest, error) { + request := &dto.GeminiBatchEmbeddingRequest{} + err := common.UnmarshalBodyReusable(c, request) + if err != nil { + return nil, err + } + return request, nil +} diff --git a/relay/image_handler.go b/relay/image_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..b5896840232e1b80f409b9c66dec72d81493c978 --- /dev/null +++ b/relay/image_handler.go @@ -0,0 +1,135 @@ +package relay + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + imageReq, ok := info.Request.(*dto.ImageRequest) + if !ok { + return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected dto.ImageRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(imageReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to ImageRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + var requestBody io.Reader + + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + requestBody = bytes.NewBuffer(body) + } else { + convertedRequest, err := adaptor.ConvertImageRequest(c, info, *request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + + switch convertedRequest.(type) { + case *bytes.Buffer: + requestBody = convertedRequest.(io.Reader) + default: + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + // apply param override + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + + if common.DebugEnabled { + logger.LogDebug(c, fmt.Sprintf("image request body: %s", string(jsonData))) + } + requestBody = bytes.NewBuffer(jsonData) + } + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if httpResp.StatusCode != http.StatusOK { + if httpResp.StatusCode == http.StatusCreated && info.ApiType == constant.APITypeReplicate { + // replicate channel returns 201 Created when using Prefer: wait, treat it as success. + httpResp.StatusCode = http.StatusOK + } else { + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + + if usage.(*dto.Usage).TotalTokens == 0 { + usage.(*dto.Usage).TotalTokens = int(request.N) + } + if usage.(*dto.Usage).PromptTokens == 0 { + usage.(*dto.Usage).PromptTokens = int(request.N) + } + + quality := "standard" + if request.Quality == "hd" { + quality = "hd" + } + + var logContent string + + if len(request.Size) > 0 { + logContent = fmt.Sprintf("大小 %s, 品质 %s, 张数 %d", request.Size, quality, request.N) + } + + postConsumeQuota(c, info, usage.(*dto.Usage), logContent) + return nil +} diff --git a/relay/mjproxy_handler.go b/relay/mjproxy_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..8916ab181fd90327737438e3d84fa80194fb0b6a --- /dev/null +++ b/relay/mjproxy_handler.go @@ -0,0 +1,660 @@ +package relay + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-gonic/gin" +) + +func RelayMidjourneyImage(c *gin.Context) { + taskId := c.Param("id") + midjourneyTask := model.GetByOnlyMJId(taskId) + if midjourneyTask == nil { + c.JSON(400, gin.H{ + "error": "midjourney_task_not_found", + }) + return + } + var httpClient *http.Client + if channel, err := model.CacheGetChannel(midjourneyTask.ChannelId); err == nil { + proxy := channel.GetSetting().Proxy + if proxy != "" { + if httpClient, err = service.NewProxyHttpClient(proxy); err != nil { + c.JSON(400, gin.H{ + "error": "proxy_url_invalid", + }) + return + } + } + } + if httpClient == nil { + httpClient = service.GetHttpClient() + } + resp, err := httpClient.Get(midjourneyTask.ImageUrl) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "http_get_image_failed", + }) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + responseBody, _ := io.ReadAll(resp.Body) + c.JSON(resp.StatusCode, gin.H{ + "error": string(responseBody), + }) + return + } + // 从Content-Type头获取MIME类型 + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + // 如果无法确定内容类型,则默认为jpeg + contentType = "image/jpeg" + } + // 设置响应的内容类型 + c.Writer.Header().Set("Content-Type", contentType) + // 将图片流式传输到响应体 + _, err = io.Copy(c.Writer, resp.Body) + if err != nil { + log.Println("Failed to stream image:", err) + } + return +} + +func RelayMidjourneyNotify(c *gin.Context) *dto.MidjourneyResponse { + var midjRequest dto.MidjourneyDto + err := common.UnmarshalBodyReusable(c, &midjRequest) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "bind_request_body_failed", + Properties: nil, + Result: "", + } + } + midjourneyTask := model.GetByOnlyMJId(midjRequest.MjId) + if midjourneyTask == nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "midjourney_task_not_found", + Properties: nil, + Result: "", + } + } + midjourneyTask.Progress = midjRequest.Progress + midjourneyTask.PromptEn = midjRequest.PromptEn + midjourneyTask.State = midjRequest.State + midjourneyTask.SubmitTime = midjRequest.SubmitTime + midjourneyTask.StartTime = midjRequest.StartTime + midjourneyTask.FinishTime = midjRequest.FinishTime + midjourneyTask.ImageUrl = midjRequest.ImageUrl + midjourneyTask.VideoUrl = midjRequest.VideoUrl + videoUrlsStr, _ := json.Marshal(midjRequest.VideoUrls) + midjourneyTask.VideoUrls = string(videoUrlsStr) + midjourneyTask.Status = midjRequest.Status + midjourneyTask.FailReason = midjRequest.FailReason + err = midjourneyTask.Update() + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "update_midjourney_task_failed", + } + } + + return nil +} + +func coverMidjourneyTaskDto(c *gin.Context, originTask *model.Midjourney) (midjourneyTask dto.MidjourneyDto) { + midjourneyTask.MjId = originTask.MjId + midjourneyTask.Progress = originTask.Progress + midjourneyTask.PromptEn = originTask.PromptEn + midjourneyTask.State = originTask.State + midjourneyTask.SubmitTime = originTask.SubmitTime + midjourneyTask.StartTime = originTask.StartTime + midjourneyTask.FinishTime = originTask.FinishTime + midjourneyTask.ImageUrl = "" + if originTask.ImageUrl != "" && setting.MjForwardUrlEnabled { + midjourneyTask.ImageUrl = system_setting.ServerAddress + "/mj/image/" + originTask.MjId + if originTask.Status != "SUCCESS" { + midjourneyTask.ImageUrl += "?rand=" + strconv.FormatInt(time.Now().UnixNano(), 10) + } + } else { + midjourneyTask.ImageUrl = originTask.ImageUrl + } + if originTask.VideoUrl != "" { + midjourneyTask.VideoUrl = originTask.VideoUrl + } + midjourneyTask.Status = originTask.Status + midjourneyTask.FailReason = originTask.FailReason + midjourneyTask.Action = originTask.Action + midjourneyTask.Description = originTask.Description + midjourneyTask.Prompt = originTask.Prompt + if originTask.Buttons != "" { + var buttons []dto.ActionButton + err := json.Unmarshal([]byte(originTask.Buttons), &buttons) + if err == nil { + midjourneyTask.Buttons = buttons + } + } + if originTask.VideoUrls != "" { + var videoUrls []dto.ImgUrls + err := json.Unmarshal([]byte(originTask.VideoUrls), &videoUrls) + if err == nil { + midjourneyTask.VideoUrls = videoUrls + } + } + if originTask.Properties != "" { + var properties dto.Properties + err := json.Unmarshal([]byte(originTask.Properties), &properties) + if err == nil { + midjourneyTask.Properties = &properties + } + } + return +} + +func RelaySwapFace(c *gin.Context, info *relaycommon.RelayInfo) *dto.MidjourneyResponse { + var swapFaceRequest dto.SwapFaceRequest + err := common.UnmarshalBodyReusable(c, &swapFaceRequest) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "bind_request_body_failed") + } + + info.InitChannelMeta(c) + + if swapFaceRequest.SourceBase64 == "" || swapFaceRequest.TargetBase64 == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "sour_base64_and_target_base64_is_required") + } + modelName := service.CoverActionToModelName(constant.MjActionSwapFace) + + priceData := helper.ModelPriceHelperPerCall(c, info) + + userQuota, err := model.GetUserQuota(info.UserId, false) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: err.Error(), + } + } + + if userQuota-priceData.Quota < 0 { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "quota_not_enough", + } + } + requestURL := getMjRequestPath(c.Request.URL.String()) + baseURL := c.GetString("base_url") + fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL) + mjResp, _, err := service.DoMidjourneyHttpRequest(c, time.Second*60, fullRequestURL) + if err != nil { + return &mjResp.Response + } + defer func() { + if mjResp.StatusCode == 200 && mjResp.Response.Code == 1 { + err := service.PostConsumeQuota(info, priceData.Quota, 0, true) + if err != nil { + common.SysLog("error consuming token remain quota: " + err.Error()) + } + + tokenName := c.GetString("token_name") + logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, constant.MjActionSwapFace) + other := service.GenerateMjOtherInfo(info, priceData) + model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{ + ChannelId: info.ChannelId, + ModelName: modelName, + TokenName: tokenName, + Quota: priceData.Quota, + Content: logContent, + TokenId: info.TokenId, + Group: info.UsingGroup, + Other: other, + }) + model.UpdateUserUsedQuotaAndRequestCount(info.UserId, priceData.Quota) + model.UpdateChannelUsedQuota(info.ChannelId, priceData.Quota) + } + }() + midjResponse := &mjResp.Response + midjourneyTask := &model.Midjourney{ + UserId: info.UserId, + Code: midjResponse.Code, + Action: constant.MjActionSwapFace, + MjId: midjResponse.Result, + Prompt: "InsightFace", + PromptEn: "", + Description: midjResponse.Description, + State: "", + SubmitTime: info.StartTime.UnixNano() / int64(time.Millisecond), + StartTime: time.Now().UnixNano() / int64(time.Millisecond), + FinishTime: 0, + ImageUrl: "", + Status: "", + Progress: "0%", + FailReason: "", + ChannelId: c.GetInt("channel_id"), + Quota: priceData.Quota, + } + err = midjourneyTask.Insert() + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "insert_midjourney_task_failed") + } + c.Writer.WriteHeader(mjResp.StatusCode) + respBody, err := json.Marshal(midjResponse) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "unmarshal_response_body_failed") + } + _, err = io.Copy(c.Writer, bytes.NewBuffer(respBody)) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "copy_response_body_failed") + } + return nil +} + +func RelayMidjourneyTaskImageSeed(c *gin.Context) *dto.MidjourneyResponse { + taskId := c.Param("id") + userId := c.GetInt("id") + originTask := model.GetByMJId(userId, taskId) + if originTask == nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "task_no_found") + } + channel, err := model.GetChannelById(originTask.ChannelId, true) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "get_channel_info_failed") + } + if channel.Status != common.ChannelStatusEnabled { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "该任务所属渠道已被禁用") + } + c.Set("channel_id", originTask.ChannelId) + c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) + + requestURL := getMjRequestPath(c.Request.URL.String()) + fullRequestURL := fmt.Sprintf("%s%s", channel.GetBaseURL(), requestURL) + midjResponseWithStatus, _, err := service.DoMidjourneyHttpRequest(c, time.Second*30, fullRequestURL) + if err != nil { + return &midjResponseWithStatus.Response + } + midjResponse := &midjResponseWithStatus.Response + c.Writer.WriteHeader(midjResponseWithStatus.StatusCode) + respBody, err := json.Marshal(midjResponse) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "unmarshal_response_body_failed") + } + service.IOCopyBytesGracefully(c, nil, respBody) + return nil +} + +func RelayMidjourneyTask(c *gin.Context, relayMode int) *dto.MidjourneyResponse { + userId := c.GetInt("id") + var err error + var respBody []byte + switch relayMode { + case relayconstant.RelayModeMidjourneyTaskFetch: + taskId := c.Param("id") + originTask := model.GetByMJId(userId, taskId) + if originTask == nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "task_no_found", + } + } + midjourneyTask := coverMidjourneyTaskDto(c, originTask) + respBody, err = json.Marshal(midjourneyTask) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "unmarshal_response_body_failed", + } + } + case relayconstant.RelayModeMidjourneyTaskFetchByCondition: + var condition = struct { + IDs []string `json:"ids"` + }{} + err = c.BindJSON(&condition) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "do_request_failed", + } + } + var tasks []dto.MidjourneyDto + if len(condition.IDs) != 0 { + originTasks := model.GetByMJIds(userId, condition.IDs) + for _, originTask := range originTasks { + midjourneyTask := coverMidjourneyTaskDto(c, originTask) + tasks = append(tasks, midjourneyTask) + } + } + if tasks == nil { + tasks = make([]dto.MidjourneyDto, 0) + } + respBody, err = json.Marshal(tasks) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "unmarshal_response_body_failed", + } + } + } + + c.Writer.Header().Set("Content-Type", "application/json") + + _, err = io.Copy(c.Writer, bytes.NewBuffer(respBody)) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "copy_response_body_failed", + } + } + return nil +} + +func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dto.MidjourneyResponse { + consumeQuota := true + var midjRequest dto.MidjourneyRequest + err := common.UnmarshalBodyReusable(c, &midjRequest) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "bind_request_body_failed") + } + + relayInfo.InitChannelMeta(c) + + if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyAction { // midjourney plus,需要从customId中获取任务信息 + mjErr := service.CoverPlusActionToNormalAction(&midjRequest) + if mjErr != nil { + return mjErr + } + relayInfo.RelayMode = relayconstant.RelayModeMidjourneyChange + } + if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyVideo { + midjRequest.Action = constant.MjActionVideo + } + + if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyImagine { //绘画任务,此类任务可重复 + if midjRequest.Prompt == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "prompt_is_required") + } + midjRequest.Action = constant.MjActionImagine + } else if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyDescribe { //按图生文任务,此类任务可重复 + midjRequest.Action = constant.MjActionDescribe + } else if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyEdits { //编辑任务,此类任务可重复 + midjRequest.Action = constant.MjActionEdits + } else if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyShorten { //缩短任务,此类任务可重复,plus only + midjRequest.Action = constant.MjActionShorten + } else if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyBlend { //绘画任务,此类任务可重复 + midjRequest.Action = constant.MjActionBlend + } else if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyUpload { //绘画任务,此类任务可重复 + midjRequest.Action = constant.MjActionUpload + } else if midjRequest.TaskId != "" { //放大、变换任务,此类任务,如果重复且已有结果,远端api会直接返回最终结果 + mjId := "" + if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyChange { + if midjRequest.TaskId == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "task_id_is_required") + } else if midjRequest.Action == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "action_is_required") + } else if midjRequest.Index == 0 { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "index_is_required") + } + //action = midjRequest.Action + mjId = midjRequest.TaskId + } else if relayInfo.RelayMode == relayconstant.RelayModeMidjourneySimpleChange { + if midjRequest.Content == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "content_is_required") + } + params := service.ConvertSimpleChangeParams(midjRequest.Content) + if params == nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "content_parse_failed") + } + mjId = params.TaskId + midjRequest.Action = params.Action + } else if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyModal { + //if midjRequest.MaskBase64 == "" { + // return service.MidjourneyErrorWrapper(constant.MjRequestError, "mask_base64_is_required") + //} + mjId = midjRequest.TaskId + midjRequest.Action = constant.MjActionModal + } else if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyVideo { + midjRequest.Action = constant.MjActionVideo + if midjRequest.TaskId == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "task_id_is_required") + } else if midjRequest.Action == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "action_is_required") + } + mjId = midjRequest.TaskId + } + + originTask := model.GetByMJId(relayInfo.UserId, mjId) + if originTask == nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "task_not_found") + } else { //原任务的Status=SUCCESS,则可以做放大UPSCALE、变换VARIATION等动作,此时必须使用原来的请求地址才能正确处理 + if setting.MjActionCheckSuccessEnabled { + if originTask.Status != "SUCCESS" && relayInfo.RelayMode != relayconstant.RelayModeMidjourneyModal { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "task_status_not_success") + } + } + channel, err := model.GetChannelById(originTask.ChannelId, true) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "get_channel_info_failed") + } + if channel.Status != common.ChannelStatusEnabled { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "该任务所属渠道已被禁用") + } + c.Set("base_url", channel.GetBaseURL()) + c.Set("channel_id", originTask.ChannelId) + c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) + log.Printf("检测到此操作为放大、变换、重绘,获取原channel信息: %s,%s", strconv.Itoa(originTask.ChannelId), channel.GetBaseURL()) + } + midjRequest.Prompt = originTask.Prompt + + //if channelType == common.ChannelTypeMidjourneyPlus { + // // plus + //} else { + // // 普通版渠道 + // + //} + } + + if midjRequest.Action == constant.MjActionInPaint || midjRequest.Action == constant.MjActionCustomZoom { + consumeQuota = false + } + + //baseURL := common.ChannelBaseURLs[channelType] + requestURL := getMjRequestPath(c.Request.URL.String()) + + baseURL := c.GetString("base_url") + + //midjRequest.NotifyHook = "http://127.0.0.1:3000/mj/notify" + + fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL) + + modelName := service.CoverActionToModelName(midjRequest.Action) + + priceData := helper.ModelPriceHelperPerCall(c, relayInfo) + + userQuota, err := model.GetUserQuota(relayInfo.UserId, false) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: err.Error(), + } + } + + if consumeQuota && userQuota-priceData.Quota < 0 { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "quota_not_enough", + } + } + + midjResponseWithStatus, responseBody, err := service.DoMidjourneyHttpRequest(c, time.Second*60, fullRequestURL) + if err != nil { + return &midjResponseWithStatus.Response + } + midjResponse := &midjResponseWithStatus.Response + + defer func() { + if consumeQuota && midjResponseWithStatus.StatusCode == 200 { + err := service.PostConsumeQuota(relayInfo, priceData.Quota, 0, true) + if err != nil { + common.SysLog("error consuming token remain quota: " + err.Error()) + } + tokenName := c.GetString("token_name") + logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s,ID %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, midjRequest.Action, midjResponse.Result) + other := service.GenerateMjOtherInfo(relayInfo, priceData) + model.RecordConsumeLog(c, relayInfo.UserId, model.RecordConsumeLogParams{ + ChannelId: relayInfo.ChannelId, + ModelName: modelName, + TokenName: tokenName, + Quota: priceData.Quota, + Content: logContent, + TokenId: relayInfo.TokenId, + Group: relayInfo.UsingGroup, + Other: other, + }) + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, priceData.Quota) + model.UpdateChannelUsedQuota(relayInfo.ChannelId, priceData.Quota) + } + }() + + // 文档:https://github.com/novicezk/midjourney-proxy/blob/main/docs/api.md + //1-提交成功 + // 21-任务已存在(处理中或者有结果了) {"code":21,"description":"任务已存在","result":"0741798445574458","properties":{"status":"SUCCESS","imageUrl":"https://xxxx"}} + // 22-排队中 {"code":22,"description":"排队中,前面还有1个任务","result":"0741798445574458","properties":{"numberOfQueues":1,"discordInstanceId":"1118138338562560102"}} + // 23-队列已满,请稍后再试 {"code":23,"description":"队列已满,请稍后尝试","result":"14001929738841620","properties":{"discordInstanceId":"1118138338562560102"}} + // 24-prompt包含敏感词 {"code":24,"description":"可能包含敏感词","properties":{"promptEn":"nude body","bannedWord":"nude"}} + // other: 提交错误,description为错误描述 + midjourneyTask := &model.Midjourney{ + UserId: relayInfo.UserId, + Code: midjResponse.Code, + Action: midjRequest.Action, + MjId: midjResponse.Result, + Prompt: midjRequest.Prompt, + PromptEn: "", + Description: midjResponse.Description, + State: "", + SubmitTime: time.Now().UnixNano() / int64(time.Millisecond), + StartTime: 0, + FinishTime: 0, + ImageUrl: "", + Status: "", + Progress: "0%", + FailReason: "", + ChannelId: c.GetInt("channel_id"), + Quota: priceData.Quota, + } + if midjResponse.Code == 3 { + //无实例账号自动禁用渠道(No available account instance) + channel, err := model.GetChannelById(midjourneyTask.ChannelId, true) + if err != nil { + common.SysLog("get_channel_null: " + err.Error()) + } + if channel.GetAutoBan() && common.AutomaticDisableChannelEnabled { + model.UpdateChannelStatus(midjourneyTask.ChannelId, "", 2, "No available account instance") + } + } + if midjResponse.Code != 1 && midjResponse.Code != 21 && midjResponse.Code != 22 { + //非1-提交成功,21-任务已存在和22-排队中,则记录错误原因 + midjourneyTask.FailReason = midjResponse.Description + consumeQuota = false + } + + if midjResponse.Code == 21 { //21-任务已存在(处理中或者有结果了) + // 将 properties 转换为一个 map + properties, ok := midjResponse.Properties.(map[string]interface{}) + if ok { + imageUrl, ok1 := properties["imageUrl"].(string) + status, ok2 := properties["status"].(string) + if ok1 && ok2 { + midjourneyTask.ImageUrl = imageUrl + midjourneyTask.Status = status + if status == "SUCCESS" { + midjourneyTask.Progress = "100%" + midjourneyTask.StartTime = time.Now().UnixNano() / int64(time.Millisecond) + midjourneyTask.FinishTime = time.Now().UnixNano() / int64(time.Millisecond) + midjResponse.Code = 1 + } + } + } + //修改返回值 + if midjRequest.Action != constant.MjActionInPaint && midjRequest.Action != constant.MjActionCustomZoom { + newBody := strings.Replace(string(responseBody), `"code":21`, `"code":1`, -1) + responseBody = []byte(newBody) + } + } + if midjResponse.Code == 1 && midjRequest.Action == "UPLOAD" { + midjourneyTask.Progress = "100%" + midjourneyTask.Status = "SUCCESS" + } + err = midjourneyTask.Insert() + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "insert_midjourney_task_failed", + } + } + + if midjResponse.Code == 22 { //22-排队中,说明任务已存在 + //修改返回值 + newBody := strings.Replace(string(responseBody), `"code":22`, `"code":1`, -1) + responseBody = []byte(newBody) + } + //resp.Body = io.NopCloser(bytes.NewBuffer(responseBody)) + bodyReader := io.NopCloser(bytes.NewBuffer(responseBody)) + + //for k, v := range resp.Header { + // c.Writer.Header().Set(k, v[0]) + //} + c.Writer.WriteHeader(midjResponseWithStatus.StatusCode) + + _, err = io.Copy(c.Writer, bodyReader) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "copy_response_body_failed", + } + } + err = bodyReader.Close() + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "close_response_body_failed", + } + } + return nil +} + +type taskChangeParams struct { + ID string + Action string + Index int +} + +func getMjRequestPath(path string) string { + requestURL := path + if strings.Contains(requestURL, "/mj-") { + urls := strings.Split(requestURL, "/mj/") + if len(urls) < 2 { + return requestURL + } + requestURL = "/mj/" + urls[1] + } + return requestURL +} diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..b838b313d94f28e7c48f5b7e94470f1b0b8e3f4b --- /dev/null +++ b/relay/relay_adaptor.go @@ -0,0 +1,162 @@ +package relay + +import ( + "strconv" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/ali" + "github.com/QuantumNous/new-api/relay/channel/aws" + "github.com/QuantumNous/new-api/relay/channel/baidu" + "github.com/QuantumNous/new-api/relay/channel/baidu_v2" + "github.com/QuantumNous/new-api/relay/channel/claude" + "github.com/QuantumNous/new-api/relay/channel/cloudflare" + "github.com/QuantumNous/new-api/relay/channel/cohere" + "github.com/QuantumNous/new-api/relay/channel/coze" + "github.com/QuantumNous/new-api/relay/channel/deepseek" + "github.com/QuantumNous/new-api/relay/channel/dify" + "github.com/QuantumNous/new-api/relay/channel/gemini" + "github.com/QuantumNous/new-api/relay/channel/jimeng" + "github.com/QuantumNous/new-api/relay/channel/jina" + "github.com/QuantumNous/new-api/relay/channel/minimax" + "github.com/QuantumNous/new-api/relay/channel/mistral" + "github.com/QuantumNous/new-api/relay/channel/mokaai" + "github.com/QuantumNous/new-api/relay/channel/moonshot" + "github.com/QuantumNous/new-api/relay/channel/ollama" + "github.com/QuantumNous/new-api/relay/channel/openai" + "github.com/QuantumNous/new-api/relay/channel/palm" + "github.com/QuantumNous/new-api/relay/channel/perplexity" + "github.com/QuantumNous/new-api/relay/channel/replicate" + "github.com/QuantumNous/new-api/relay/channel/siliconflow" + "github.com/QuantumNous/new-api/relay/channel/submodel" + taskali "github.com/QuantumNous/new-api/relay/channel/task/ali" + taskdoubao "github.com/QuantumNous/new-api/relay/channel/task/doubao" + taskGemini "github.com/QuantumNous/new-api/relay/channel/task/gemini" + "github.com/QuantumNous/new-api/relay/channel/task/hailuo" + taskjimeng "github.com/QuantumNous/new-api/relay/channel/task/jimeng" + "github.com/QuantumNous/new-api/relay/channel/task/kling" + tasksora "github.com/QuantumNous/new-api/relay/channel/task/sora" + "github.com/QuantumNous/new-api/relay/channel/task/suno" + taskvertex "github.com/QuantumNous/new-api/relay/channel/task/vertex" + taskVidu "github.com/QuantumNous/new-api/relay/channel/task/vidu" + "github.com/QuantumNous/new-api/relay/channel/tencent" + "github.com/QuantumNous/new-api/relay/channel/vertex" + "github.com/QuantumNous/new-api/relay/channel/volcengine" + "github.com/QuantumNous/new-api/relay/channel/xai" + "github.com/QuantumNous/new-api/relay/channel/xunfei" + "github.com/QuantumNous/new-api/relay/channel/zhipu" + "github.com/QuantumNous/new-api/relay/channel/zhipu_4v" + "github.com/gin-gonic/gin" +) + +func GetAdaptor(apiType int) channel.Adaptor { + switch apiType { + case constant.APITypeAli: + return &ali.Adaptor{} + case constant.APITypeAnthropic: + return &claude.Adaptor{} + case constant.APITypeBaidu: + return &baidu.Adaptor{} + case constant.APITypeGemini: + return &gemini.Adaptor{} + case constant.APITypeOpenAI: + return &openai.Adaptor{} + case constant.APITypePaLM: + return &palm.Adaptor{} + case constant.APITypeTencent: + return &tencent.Adaptor{} + case constant.APITypeXunfei: + return &xunfei.Adaptor{} + case constant.APITypeZhipu: + return &zhipu.Adaptor{} + case constant.APITypeZhipuV4: + return &zhipu_4v.Adaptor{} + case constant.APITypeOllama: + return &ollama.Adaptor{} + case constant.APITypePerplexity: + return &perplexity.Adaptor{} + case constant.APITypeAws: + return &aws.Adaptor{} + case constant.APITypeCohere: + return &cohere.Adaptor{} + case constant.APITypeDify: + return &dify.Adaptor{} + case constant.APITypeJina: + return &jina.Adaptor{} + case constant.APITypeCloudflare: + return &cloudflare.Adaptor{} + case constant.APITypeSiliconFlow: + return &siliconflow.Adaptor{} + case constant.APITypeVertexAi: + return &vertex.Adaptor{} + case constant.APITypeMistral: + return &mistral.Adaptor{} + case constant.APITypeDeepSeek: + return &deepseek.Adaptor{} + case constant.APITypeMokaAI: + return &mokaai.Adaptor{} + case constant.APITypeVolcEngine: + return &volcengine.Adaptor{} + case constant.APITypeBaiduV2: + return &baidu_v2.Adaptor{} + case constant.APITypeOpenRouter: + return &openai.Adaptor{} + case constant.APITypeXinference: + return &openai.Adaptor{} + case constant.APITypeXai: + return &xai.Adaptor{} + case constant.APITypeCoze: + return &coze.Adaptor{} + case constant.APITypeJimeng: + return &jimeng.Adaptor{} + case constant.APITypeMoonshot: + return &moonshot.Adaptor{} // Moonshot uses Claude API + case constant.APITypeSubmodel: + return &submodel.Adaptor{} + case constant.APITypeMiniMax: + return &minimax.Adaptor{} + case constant.APITypeReplicate: + return &replicate.Adaptor{} + } + return nil +} + +func GetTaskPlatform(c *gin.Context) constant.TaskPlatform { + channelType := c.GetInt("channel_type") + if channelType > 0 { + return constant.TaskPlatform(strconv.Itoa(channelType)) + } + return constant.TaskPlatform(c.GetString("platform")) +} + +func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { + switch platform { + //case constant.APITypeAIProxyLibrary: + // return &aiproxy.Adaptor{} + case constant.TaskPlatformSuno: + return &suno.TaskAdaptor{} + } + if channelType, err := strconv.ParseInt(string(platform), 10, 64); err == nil { + switch channelType { + case constant.ChannelTypeAli: + return &taskali.TaskAdaptor{} + case constant.ChannelTypeKling: + return &kling.TaskAdaptor{} + case constant.ChannelTypeJimeng: + return &taskjimeng.TaskAdaptor{} + case constant.ChannelTypeVertexAi: + return &taskvertex.TaskAdaptor{} + case constant.ChannelTypeVidu: + return &taskVidu.TaskAdaptor{} + case constant.ChannelTypeDoubaoVideo: + return &taskdoubao.TaskAdaptor{} + case constant.ChannelTypeSora, constant.ChannelTypeOpenAI: + return &tasksora.TaskAdaptor{} + case constant.ChannelTypeGemini: + return &taskGemini.TaskAdaptor{} + case constant.ChannelTypeMiniMax: + return &hailuo.TaskAdaptor{} + } + } + return nil +} diff --git a/relay/relay_task.go b/relay/relay_task.go new file mode 100644 index 0000000000000000000000000000000000000000..61e2af5230a1c25269ab7a98b8817fadffc03f7d --- /dev/null +++ b/relay/relay_task.go @@ -0,0 +1,446 @@ +package relay + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +/* +Task 任务通过平台、Action 区分任务 +*/ +func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + info.InitChannelMeta(c) + // ensure TaskRelayInfo is initialized to avoid nil dereference when accessing embedded fields + if info.TaskRelayInfo == nil { + info.TaskRelayInfo = &relaycommon.TaskRelayInfo{} + } + platform := constant.TaskPlatform(c.GetString("platform")) + if platform == "" { + platform = GetTaskPlatform(c) + } + + info.InitChannelMeta(c) + adaptor := GetTaskAdaptor(platform) + if adaptor == nil { + return service.TaskErrorWrapperLocal(fmt.Errorf("invalid api platform: %s", platform), "invalid_api_platform", http.StatusBadRequest) + } + adaptor.Init(info) + // get & validate taskRequest 获取并验证文本请求 + taskErr = adaptor.ValidateRequestAndSetAction(c, info) + if taskErr != nil { + return + } + + modelName := info.OriginModelName + if modelName == "" { + modelName = service.CoverTaskActionToModelName(platform, info.Action) + } + modelPrice, success := ratio_setting.GetModelPrice(modelName, true) + if !success { + defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[modelName] + if !ok { + modelPrice = 0.1 + } else { + modelPrice = defaultPrice + } + } + + // 预扣 + groupRatio := ratio_setting.GetGroupRatio(info.UsingGroup) + var ratio float64 + userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(info.UserGroup, info.UsingGroup) + if hasUserGroupRatio { + ratio = modelPrice * userGroupRatio + } else { + ratio = modelPrice * groupRatio + } + // FIXME: 临时修补,支持任务仅按次计费 + if !common.StringsContains(constant.TaskPricePatches, modelName) { + if len(info.PriceData.OtherRatios) > 0 { + for _, ra := range info.PriceData.OtherRatios { + if 1.0 != ra { + ratio *= ra + } + } + } + } + println(fmt.Sprintf("model: %s, model_price: %.4f, group: %s, group_ratio: %.4f, final_ratio: %.4f", modelName, modelPrice, info.UsingGroup, groupRatio, ratio)) + userQuota, err := model.GetUserQuota(info.UserId, false) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError) + return + } + quota := int(ratio * common.QuotaPerUnit) + if userQuota-quota < 0 { + taskErr = service.TaskErrorWrapperLocal(errors.New("user quota is not enough"), "quota_not_enough", http.StatusForbidden) + return + } + + if info.OriginTaskID != "" { + originTask, exist, err := model.GetByTaskId(info.UserId, info.OriginTaskID) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "get_origin_task_failed", http.StatusInternalServerError) + return + } + if !exist { + taskErr = service.TaskErrorWrapperLocal(errors.New("task_origin_not_exist"), "task_not_exist", http.StatusBadRequest) + return + } + if originTask.ChannelId != info.ChannelId { + channel, err := model.GetChannelById(originTask.ChannelId, true) + if err != nil { + taskErr = service.TaskErrorWrapperLocal(err, "channel_not_found", http.StatusBadRequest) + return + } + if channel.Status != common.ChannelStatusEnabled { + return service.TaskErrorWrapperLocal(errors.New("该任务所属渠道已被禁用"), "task_channel_disable", http.StatusBadRequest) + } + c.Set("base_url", channel.GetBaseURL()) + c.Set("channel_id", originTask.ChannelId) + c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) + + info.ChannelBaseUrl = channel.GetBaseURL() + info.ChannelId = originTask.ChannelId + } + } + + // build body + requestBody, err := adaptor.BuildRequestBody(c, info) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "build_request_failed", http.StatusInternalServerError) + return + } + // do request + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "do_request_failed", http.StatusInternalServerError) + return + } + // handle response + if resp != nil && resp.StatusCode != http.StatusOK { + responseBody, _ := io.ReadAll(resp.Body) + taskErr = service.TaskErrorWrapper(fmt.Errorf(string(responseBody)), "fail_to_fetch_task", resp.StatusCode) + return + } + + defer func() { + // release quota + if info.ConsumeQuota && taskErr == nil { + + err := service.PostConsumeQuota(info, quota, 0, true) + if err != nil { + common.SysLog("error consuming token remain quota: " + err.Error()) + } + if quota != 0 { + tokenName := c.GetString("token_name") + //gRatio := groupRatio + //if hasUserGroupRatio { + // gRatio = userGroupRatio + //} + logContent := fmt.Sprintf("操作 %s", info.Action) + // FIXME: 临时修补,支持任务仅按次计费 + if common.StringsContains(constant.TaskPricePatches, modelName) { + logContent = fmt.Sprintf("%s,按次计费", logContent) + } else { + if len(info.PriceData.OtherRatios) > 0 { + var contents []string + for key, ra := range info.PriceData.OtherRatios { + if 1.0 != ra { + contents = append(contents, fmt.Sprintf("%s: %.2f", key, ra)) + } + } + if len(contents) > 0 { + logContent = fmt.Sprintf("%s, 计算参数:%s", logContent, strings.Join(contents, ", ")) + } + } + } + other := make(map[string]interface{}) + if c != nil && c.Request != nil && c.Request.URL != nil { + other["request_path"] = c.Request.URL.Path + } + other["model_price"] = modelPrice + other["group_ratio"] = groupRatio + if hasUserGroupRatio { + other["user_group_ratio"] = userGroupRatio + } + model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{ + ChannelId: info.ChannelId, + ModelName: modelName, + TokenName: tokenName, + Quota: quota, + Content: logContent, + TokenId: info.TokenId, + Group: info.UsingGroup, + Other: other, + }) + model.UpdateUserUsedQuotaAndRequestCount(info.UserId, quota) + model.UpdateChannelUsedQuota(info.ChannelId, quota) + } + } + }() + + taskID, taskData, taskErr := adaptor.DoResponse(c, resp, info) + if taskErr != nil { + return + } + info.ConsumeQuota = true + // insert task + task := model.InitTask(platform, info) + task.TaskID = taskID + task.Quota = quota + task.Data = taskData + task.Action = info.Action + err = task.Insert() + if err != nil { + taskErr = service.TaskErrorWrapper(err, "insert_task_failed", http.StatusInternalServerError) + return + } + return nil +} + +var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){ + relayconstant.RelayModeSunoFetchByID: sunoFetchByIDRespBodyBuilder, + relayconstant.RelayModeSunoFetch: sunoFetchRespBodyBuilder, + relayconstant.RelayModeVideoFetchByID: videoFetchByIDRespBodyBuilder, +} + +func RelayTaskFetch(c *gin.Context, relayMode int) (taskResp *dto.TaskError) { + respBuilder, ok := fetchRespBuilders[relayMode] + if !ok { + taskResp = service.TaskErrorWrapperLocal(errors.New("invalid_relay_mode"), "invalid_relay_mode", http.StatusBadRequest) + } + + respBody, taskErr := respBuilder(c) + if taskErr != nil { + return taskErr + } + if len(respBody) == 0 { + respBody = []byte("{\"code\":\"success\",\"data\":null}") + } + + c.Writer.Header().Set("Content-Type", "application/json") + _, err := io.Copy(c.Writer, bytes.NewBuffer(respBody)) + if err != nil { + taskResp = service.TaskErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError) + return + } + return +} + +func sunoFetchRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) { + userId := c.GetInt("id") + var condition = struct { + IDs []any `json:"ids"` + Action string `json:"action"` + }{} + err := c.BindJSON(&condition) + if err != nil { + taskResp = service.TaskErrorWrapper(err, "invalid_request", http.StatusBadRequest) + return + } + var tasks []any + if len(condition.IDs) > 0 { + taskModels, err := model.GetByTaskIds(userId, condition.IDs) + if err != nil { + taskResp = service.TaskErrorWrapper(err, "get_tasks_failed", http.StatusInternalServerError) + return + } + for _, task := range taskModels { + tasks = append(tasks, TaskModel2Dto(task)) + } + } else { + tasks = make([]any, 0) + } + respBody, err = json.Marshal(dto.TaskResponse[[]any]{ + Code: "success", + Data: tasks, + }) + return +} + +func sunoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) { + taskId := c.Param("id") + userId := c.GetInt("id") + + originTask, exist, err := model.GetByTaskId(userId, taskId) + if err != nil { + taskResp = service.TaskErrorWrapper(err, "get_task_failed", http.StatusInternalServerError) + return + } + if !exist { + taskResp = service.TaskErrorWrapperLocal(errors.New("task_not_exist"), "task_not_exist", http.StatusBadRequest) + return + } + + respBody, err = json.Marshal(dto.TaskResponse[any]{ + Code: "success", + Data: TaskModel2Dto(originTask), + }) + return +} + +func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) { + taskId := c.Param("task_id") + if taskId == "" { + taskId = c.GetString("task_id") + } + userId := c.GetInt("id") + + originTask, exist, err := model.GetByTaskId(userId, taskId) + if err != nil { + taskResp = service.TaskErrorWrapper(err, "get_task_failed", http.StatusInternalServerError) + return + } + if !exist { + taskResp = service.TaskErrorWrapperLocal(errors.New("task_not_exist"), "task_not_exist", http.StatusBadRequest) + return + } + + func() { + channelModel, err2 := model.GetChannelById(originTask.ChannelId, true) + if err2 != nil { + return + } + if channelModel.Type != constant.ChannelTypeVertexAi && channelModel.Type != constant.ChannelTypeGemini { + return + } + baseURL := constant.ChannelBaseURLs[channelModel.Type] + if channelModel.GetBaseURL() != "" { + baseURL = channelModel.GetBaseURL() + } + adaptor := GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channelModel.Type))) + if adaptor == nil { + return + } + resp, err2 := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{ + "task_id": originTask.TaskID, + "action": originTask.Action, + }) + if err2 != nil || resp == nil { + return + } + defer resp.Body.Close() + body, err2 := io.ReadAll(resp.Body) + if err2 != nil { + return + } + ti, err2 := adaptor.ParseTaskResult(body) + if err2 == nil && ti != nil { + if ti.Status != "" { + originTask.Status = model.TaskStatus(ti.Status) + } + if ti.Progress != "" { + originTask.Progress = ti.Progress + } + if ti.Url != "" { + if strings.HasPrefix(ti.Url, "data:") { + } else { + originTask.FailReason = ti.Url + } + } + _ = originTask.Update() + var raw map[string]any + _ = json.Unmarshal(body, &raw) + format := "mp4" + if respObj, ok := raw["response"].(map[string]any); ok { + if vids, ok := respObj["videos"].([]any); ok && len(vids) > 0 { + if v0, ok := vids[0].(map[string]any); ok { + if mt, ok := v0["mimeType"].(string); ok && mt != "" { + if strings.Contains(mt, "mp4") { + format = "mp4" + } else { + format = mt + } + } + } + } + } + status := "processing" + switch originTask.Status { + case model.TaskStatusSuccess: + status = "succeeded" + case model.TaskStatusFailure: + status = "failed" + case model.TaskStatusQueued, model.TaskStatusSubmitted: + status = "queued" + } + if !strings.HasPrefix(c.Request.RequestURI, "/v1/videos/") { + out := map[string]any{ + "error": nil, + "format": format, + "metadata": nil, + "status": status, + "task_id": originTask.TaskID, + "url": originTask.FailReason, + } + respBody, _ = json.Marshal(dto.TaskResponse[any]{ + Code: "success", + Data: out, + }) + } + } + }() + + if len(respBody) != 0 { + return + } + + if strings.HasPrefix(c.Request.RequestURI, "/v1/videos/") { + adaptor := GetTaskAdaptor(originTask.Platform) + if adaptor == nil { + taskResp = service.TaskErrorWrapperLocal(fmt.Errorf("invalid channel id: %d", originTask.ChannelId), "invalid_channel_id", http.StatusBadRequest) + return + } + if converter, ok := adaptor.(channel.OpenAIVideoConverter); ok { + openAIVideoData, err := converter.ConvertToOpenAIVideo(originTask) + if err != nil { + taskResp = service.TaskErrorWrapper(err, "convert_to_openai_video_failed", http.StatusInternalServerError) + return + } + respBody = openAIVideoData + return + } + taskResp = service.TaskErrorWrapperLocal(errors.New(fmt.Sprintf("not_implemented:%s", originTask.Platform)), "not_implemented", http.StatusNotImplemented) + return + } + respBody, err = json.Marshal(dto.TaskResponse[any]{ + Code: "success", + Data: TaskModel2Dto(originTask), + }) + if err != nil { + taskResp = service.TaskErrorWrapper(err, "marshal_response_failed", http.StatusInternalServerError) + } + return +} + +func TaskModel2Dto(task *model.Task) *dto.TaskDto { + return &dto.TaskDto{ + TaskID: task.TaskID, + Action: task.Action, + Status: string(task.Status), + FailReason: task.FailReason, + SubmitTime: task.SubmitTime, + StartTime: task.StartTime, + FinishTime: task.FinishTime, + Progress: task.Progress, + Data: task.Data, + } +} diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..3efc45079133f8a1268e8fd224986ce0a2f6172b --- /dev/null +++ b/relay/rerank_handler.go @@ -0,0 +1,100 @@ +package relay + +import ( + "bytes" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + rerankReq, ok := info.Request.(*dto.RerankRequest) + if !ok { + return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected dto.RerankRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(rerankReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to ImageRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + var requestBody io.Reader + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + requestBody = bytes.NewBuffer(body) + } else { + convertedRequest, err := adaptor.ConvertRerankRequest(c, info.RelayMode, *request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + // apply param override + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + + if common.DebugEnabled { + println(fmt.Sprintf("Rerank request body: %s", string(jsonData))) + } + requestBody = bytes.NewBuffer(jsonData) + } + + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + postConsumeQuota(c, info, usage.(*dto.Usage), "") + return nil +} diff --git a/relay/responses_handler.go b/relay/responses_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..9460356d6d531e93d95a93cbe97b0ab2dfa61c37 --- /dev/null +++ b/relay/responses_handler.go @@ -0,0 +1,113 @@ +package relay + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + responsesReq, ok := info.Request.(*dto.OpenAIResponsesRequest) + if !ok { + return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected dto.OpenAIResponsesRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(responsesReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to GeneralOpenAIRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + var requestBody io.Reader + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewError(err, types.ErrorCodeReadRequestBodyFailed, types.ErrOptionWithSkipRetry()) + } + requestBody = bytes.NewBuffer(body) + } else { + convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, info, *request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + // remove disabled fields for OpenAI Responses API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + // apply param override + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + + if common.DebugEnabled { + println("requestBody: ", string(jsonData)) + } + requestBody = bytes.NewBuffer(jsonData) + } + + var httpResp *http.Response + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + + if resp != nil { + httpResp = resp.(*http.Response) + + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + + if strings.HasPrefix(info.OriginModelName, "gpt-4o-audio") { + service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "") + } else { + postConsumeQuota(c, info, usage.(*dto.Usage), "") + } + return nil +} diff --git a/relay/websocket.go b/relay/websocket.go new file mode 100644 index 0000000000000000000000000000000000000000..57a51895b00642e4d2ae245c12da1364329f8cda --- /dev/null +++ b/relay/websocket.go @@ -0,0 +1,46 @@ +package relay + +import ( + "fmt" + + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func WssHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + //var requestBody io.Reader + //firstWssRequest, _ := c.Get("first_wss_request") + //requestBody = bytes.NewBuffer(firstWssRequest.([]byte)) + + statusCodeMappingStr := c.GetString("status_code_mapping") + resp, err := adaptor.DoRequest(c, info, nil) + if err != nil { + return types.NewError(err, types.ErrorCodeDoRequestFailed) + } + + if resp != nil { + info.TargetWs = resp.(*websocket.Conn) + defer info.TargetWs.Close() + } + + usage, newAPIError := adaptor.DoResponse(c, nil, info) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + service.PostWssConsumeQuota(c, info, info.UpstreamModelName, usage.(*dto.RealtimeUsage), "") + return nil +} diff --git a/router/api-router.go b/router/api-router.go new file mode 100644 index 0000000000000000000000000000000000000000..fd204e7e6b64a3b8ace1a04ec2e168bbfd619af5 --- /dev/null +++ b/router/api-router.go @@ -0,0 +1,260 @@ +package router + +import ( + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/middleware" + + "github.com/gin-contrib/gzip" + "github.com/gin-gonic/gin" +) + +func SetApiRouter(router *gin.Engine) { + apiRouter := router.Group("/api") + apiRouter.Use(gzip.Gzip(gzip.DefaultCompression)) + apiRouter.Use(middleware.GlobalAPIRateLimit()) + { + apiRouter.GET("/setup", controller.GetSetup) + apiRouter.POST("/setup", controller.PostSetup) + apiRouter.GET("/status", controller.GetStatus) + apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus) + apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) + apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) + apiRouter.GET("/notice", controller.GetNotice) + apiRouter.GET("/user-agreement", controller.GetUserAgreement) + apiRouter.GET("/privacy-policy", controller.GetPrivacyPolicy) + apiRouter.GET("/about", controller.GetAbout) + //apiRouter.GET("/midjourney", controller.GetMidjourney) + apiRouter.GET("/home_page_content", controller.GetHomePageContent) + apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing) + apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification) + apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) + apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword) + apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth) + apiRouter.GET("/oauth/discord", middleware.CriticalRateLimit(), controller.DiscordOAuth) + apiRouter.GET("/oauth/oidc", middleware.CriticalRateLimit(), controller.OidcAuth) + apiRouter.GET("/oauth/linuxdo", middleware.CriticalRateLimit(), controller.LinuxdoOAuth) + apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode) + apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth) + apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind) + apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind) + apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin) + apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind) + apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) + + apiRouter.POST("/stripe/webhook", controller.StripeWebhook) + apiRouter.POST("/creem/webhook", controller.CreemWebhook) + + // Universal secure verification routes + apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) + apiRouter.GET("/verify/status", middleware.UserAuth(), controller.GetVerificationStatus) + + userRoute := apiRouter.Group("/user") + { + userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) + userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login) + userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin) + userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), controller.PasskeyLoginBegin) + userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), controller.PasskeyLoginFinish) + //userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog) + userRoute.GET("/logout", controller.Logout) + userRoute.GET("/epay/notify", controller.EpayNotify) + userRoute.GET("/groups", controller.GetUserGroups) + + selfRoute := userRoute.Group("/") + selfRoute.Use(middleware.UserAuth()) + { + selfRoute.GET("/self/groups", controller.GetUserGroups) + selfRoute.GET("/self", controller.GetSelf) + selfRoute.GET("/models", controller.GetUserModels) + selfRoute.PUT("/self", controller.UpdateSelf) + selfRoute.DELETE("/self", controller.DeleteSelf) + selfRoute.GET("/token", controller.GenerateAccessToken) + selfRoute.GET("/passkey", controller.PasskeyStatus) + selfRoute.POST("/passkey/register/begin", controller.PasskeyRegisterBegin) + selfRoute.POST("/passkey/register/finish", controller.PasskeyRegisterFinish) + selfRoute.POST("/passkey/verify/begin", controller.PasskeyVerifyBegin) + selfRoute.POST("/passkey/verify/finish", controller.PasskeyVerifyFinish) + selfRoute.DELETE("/passkey", controller.PasskeyDelete) + selfRoute.GET("/aff", controller.GetAffCode) + selfRoute.GET("/topup/info", controller.GetTopUpInfo) + selfRoute.GET("/topup/self", controller.GetUserTopUps) + selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) + selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay) + selfRoute.POST("/amount", controller.RequestAmount) + selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) + selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) + selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay) + selfRoute.POST("/aff_transfer", controller.TransferAffQuota) + selfRoute.PUT("/setting", controller.UpdateUserSetting) + + // 2FA routes + selfRoute.GET("/2fa/status", controller.Get2FAStatus) + selfRoute.POST("/2fa/setup", controller.Setup2FA) + selfRoute.POST("/2fa/enable", controller.Enable2FA) + selfRoute.POST("/2fa/disable", controller.Disable2FA) + selfRoute.POST("/2fa/backup_codes", controller.RegenerateBackupCodes) + } + + adminRoute := userRoute.Group("/") + adminRoute.Use(middleware.AdminAuth()) + { + adminRoute.GET("/", controller.GetAllUsers) + adminRoute.GET("/topup", controller.GetAllTopUps) + adminRoute.POST("/topup/complete", controller.AdminCompleteTopUp) + adminRoute.GET("/search", controller.SearchUsers) + adminRoute.GET("/:id", controller.GetUser) + adminRoute.POST("/", controller.CreateUser) + adminRoute.POST("/manage", controller.ManageUser) + adminRoute.PUT("/", controller.UpdateUser) + adminRoute.DELETE("/:id", controller.DeleteUser) + adminRoute.DELETE("/:id/reset_passkey", controller.AdminResetPasskey) + + // Admin 2FA routes + adminRoute.GET("/2fa/stats", controller.Admin2FAStats) + adminRoute.DELETE("/:id/2fa", controller.AdminDisable2FA) + } + } + optionRoute := apiRouter.Group("/option") + optionRoute.Use(middleware.RootAuth()) + { + optionRoute.GET("/", controller.GetOptions) + optionRoute.PUT("/", controller.UpdateOption) + optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio) + optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 + } + ratioSyncRoute := apiRouter.Group("/ratio_sync") + ratioSyncRoute.Use(middleware.RootAuth()) + { + ratioSyncRoute.GET("/channels", controller.GetSyncableChannels) + ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios) + } + channelRoute := apiRouter.Group("/channel") + channelRoute.Use(middleware.AdminAuth()) + { + channelRoute.GET("/", controller.GetAllChannels) + channelRoute.GET("/search", controller.SearchChannels) + channelRoute.GET("/models", controller.ChannelListModels) + channelRoute.GET("/models_enabled", controller.EnabledListModels) + channelRoute.GET("/:id", controller.GetChannel) + channelRoute.POST("/:id/key", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey) + channelRoute.GET("/test", controller.TestAllChannels) + channelRoute.GET("/test/:id", controller.TestChannel) + channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance) + channelRoute.GET("/update_balance/:id", controller.UpdateChannelBalance) + channelRoute.POST("/", controller.AddChannel) + channelRoute.PUT("/", controller.UpdateChannel) + channelRoute.DELETE("/disabled", controller.DeleteDisabledChannel) + channelRoute.POST("/tag/disabled", controller.DisableTagChannels) + channelRoute.POST("/tag/enabled", controller.EnableTagChannels) + channelRoute.PUT("/tag", controller.EditTagChannels) + channelRoute.DELETE("/:id", controller.DeleteChannel) + channelRoute.POST("/batch", controller.DeleteChannelBatch) + channelRoute.POST("/fix", controller.FixChannelsAbilities) + channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) + channelRoute.POST("/fetch_models", controller.FetchModels) + channelRoute.POST("/batch/tag", controller.BatchSetChannelTag) + channelRoute.GET("/tag/models", controller.GetTagModels) + channelRoute.POST("/copy/:id", controller.CopyChannel) + channelRoute.POST("/multi_key/manage", controller.ManageMultiKeys) + } + tokenRoute := apiRouter.Group("/token") + tokenRoute.Use(middleware.UserAuth()) + { + tokenRoute.GET("/", controller.GetAllTokens) + tokenRoute.GET("/search", controller.SearchTokens) + tokenRoute.GET("/:id", controller.GetToken) + tokenRoute.POST("/", controller.AddToken) + tokenRoute.PUT("/", controller.UpdateToken) + tokenRoute.DELETE("/:id", controller.DeleteToken) + tokenRoute.POST("/batch", controller.DeleteTokenBatch) + } + + usageRoute := apiRouter.Group("/usage") + usageRoute.Use(middleware.CriticalRateLimit()) + { + tokenUsageRoute := usageRoute.Group("/token") + tokenUsageRoute.Use(middleware.TokenAuth()) + { + tokenUsageRoute.GET("/", controller.GetTokenUsage) + } + } + + redemptionRoute := apiRouter.Group("/redemption") + redemptionRoute.Use(middleware.AdminAuth()) + { + redemptionRoute.GET("/", controller.GetAllRedemptions) + redemptionRoute.GET("/search", controller.SearchRedemptions) + redemptionRoute.GET("/:id", controller.GetRedemption) + redemptionRoute.POST("/", controller.AddRedemption) + redemptionRoute.PUT("/", controller.UpdateRedemption) + redemptionRoute.DELETE("/invalid", controller.DeleteInvalidRedemption) + redemptionRoute.DELETE("/:id", controller.DeleteRedemption) + } + logRoute := apiRouter.Group("/log") + logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs) + logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs) + logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat) + logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat) + logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs) + logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs) + logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs) + + dataRoute := apiRouter.Group("/data") + dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates) + dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates) + + logRoute.Use(middleware.CORS()) + { + logRoute.GET("/token", controller.GetLogByKey) + } + groupRoute := apiRouter.Group("/group") + groupRoute.Use(middleware.AdminAuth()) + { + groupRoute.GET("/", controller.GetGroups) + } + + prefillGroupRoute := apiRouter.Group("/prefill_group") + prefillGroupRoute.Use(middleware.AdminAuth()) + { + prefillGroupRoute.GET("/", controller.GetPrefillGroups) + prefillGroupRoute.POST("/", controller.CreatePrefillGroup) + prefillGroupRoute.PUT("/", controller.UpdatePrefillGroup) + prefillGroupRoute.DELETE("/:id", controller.DeletePrefillGroup) + } + + mjRoute := apiRouter.Group("/mj") + mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney) + mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney) + + taskRoute := apiRouter.Group("/task") + { + taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask) + taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask) + } + + vendorRoute := apiRouter.Group("/vendors") + vendorRoute.Use(middleware.AdminAuth()) + { + vendorRoute.GET("/", controller.GetAllVendors) + vendorRoute.GET("/search", controller.SearchVendors) + vendorRoute.GET("/:id", controller.GetVendorMeta) + vendorRoute.POST("/", controller.CreateVendorMeta) + vendorRoute.PUT("/", controller.UpdateVendorMeta) + vendorRoute.DELETE("/:id", controller.DeleteVendorMeta) + } + + modelsRoute := apiRouter.Group("/models") + modelsRoute.Use(middleware.AdminAuth()) + { + modelsRoute.GET("/sync_upstream/preview", controller.SyncUpstreamPreview) + modelsRoute.POST("/sync_upstream", controller.SyncUpstreamModels) + modelsRoute.GET("/missing", controller.GetMissingModels) + modelsRoute.GET("/", controller.GetAllModelsMeta) + modelsRoute.GET("/search", controller.SearchModelsMeta) + modelsRoute.GET("/:id", controller.GetModelMeta) + modelsRoute.POST("/", controller.CreateModelMeta) + modelsRoute.PUT("/", controller.UpdateModelMeta) + modelsRoute.DELETE("/:id", controller.DeleteModelMeta) + } + } +} diff --git a/router/dashboard.go b/router/dashboard.go new file mode 100644 index 0000000000000000000000000000000000000000..17132dfb29fbace7621e1d85c905117315b1f3a9 --- /dev/null +++ b/router/dashboard.go @@ -0,0 +1,22 @@ +package router + +import ( + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/middleware" + "github.com/gin-contrib/gzip" + "github.com/gin-gonic/gin" +) + +func SetDashboardRouter(router *gin.Engine) { + apiRouter := router.Group("/") + apiRouter.Use(gzip.Gzip(gzip.DefaultCompression)) + apiRouter.Use(middleware.GlobalAPIRateLimit()) + apiRouter.Use(middleware.CORS()) + apiRouter.Use(middleware.TokenAuth()) + { + apiRouter.GET("/dashboard/billing/subscription", controller.GetSubscription) + apiRouter.GET("/v1/dashboard/billing/subscription", controller.GetSubscription) + apiRouter.GET("/dashboard/billing/usage", controller.GetUsage) + apiRouter.GET("/v1/dashboard/billing/usage", controller.GetUsage) + } +} diff --git a/router/main.go b/router/main.go new file mode 100644 index 0000000000000000000000000000000000000000..45b3080f281f6e5e0fb433d730b9d4a46da730b6 --- /dev/null +++ b/router/main.go @@ -0,0 +1,33 @@ +package router + +import ( + "embed" + "fmt" + "net/http" + "os" + "strings" + + "github.com/QuantumNous/new-api/common" + + "github.com/gin-gonic/gin" +) + +func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { + SetApiRouter(router) + SetDashboardRouter(router) + SetRelayRouter(router) + SetVideoRouter(router) + frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL") + if common.IsMasterNode && frontendBaseUrl != "" { + frontendBaseUrl = "" + common.SysLog("FRONTEND_BASE_URL is ignored on master node") + } + if frontendBaseUrl == "" { + SetWebRouter(router, buildFS, indexPage) + } else { + frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/") + router.NoRoute(func(c *gin.Context) { + c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI)) + }) + } +} diff --git a/router/relay-router.go b/router/relay-router.go new file mode 100644 index 0000000000000000000000000000000000000000..267459e2c14a335ae601c6f6571f906350e4571c --- /dev/null +++ b/router/relay-router.go @@ -0,0 +1,205 @@ +package router + +import ( + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/relay" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func SetRelayRouter(router *gin.Engine) { + router.Use(middleware.CORS()) + router.Use(middleware.DecompressRequestMiddleware()) + router.Use(middleware.StatsMiddleware()) + // https://platform.openai.com/docs/api-reference/introduction + modelsRouter := router.Group("/v1/models") + modelsRouter.Use(middleware.TokenAuth()) + { + modelsRouter.GET("", func(c *gin.Context) { + switch { + case c.GetHeader("x-api-key") != "" && c.GetHeader("anthropic-version") != "": + controller.ListModels(c, constant.ChannelTypeAnthropic) + case c.GetHeader("x-goog-api-key") != "" || c.Query("key") != "": // 单独的适配 + controller.RetrieveModel(c, constant.ChannelTypeGemini) + default: + controller.ListModels(c, constant.ChannelTypeOpenAI) + } + }) + + modelsRouter.GET("/:model", func(c *gin.Context) { + switch { + case c.GetHeader("x-api-key") != "" && c.GetHeader("anthropic-version") != "": + controller.RetrieveModel(c, constant.ChannelTypeAnthropic) + default: + controller.RetrieveModel(c, constant.ChannelTypeOpenAI) + } + }) + } + + geminiRouter := router.Group("/v1beta/models") + geminiRouter.Use(middleware.TokenAuth()) + { + geminiRouter.GET("", func(c *gin.Context) { + controller.ListModels(c, constant.ChannelTypeGemini) + }) + } + + geminiCompatibleRouter := router.Group("/v1beta/openai/models") + geminiCompatibleRouter.Use(middleware.TokenAuth()) + { + geminiCompatibleRouter.GET("", func(c *gin.Context) { + controller.ListModels(c, constant.ChannelTypeOpenAI) + }) + } + + playgroundRouter := router.Group("/pg") + playgroundRouter.Use(middleware.UserAuth(), middleware.Distribute()) + { + playgroundRouter.POST("/chat/completions", controller.Playground) + } + relayV1Router := router.Group("/v1") + relayV1Router.Use(middleware.TokenAuth()) + relayV1Router.Use(middleware.ModelRequestRateLimit()) + { + // WebSocket 路由(统一到 Relay) + wsRouter := relayV1Router.Group("") + wsRouter.Use(middleware.Distribute()) + wsRouter.GET("/realtime", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIRealtime) + }) + } + { + //http router + httpRouter := relayV1Router.Group("") + httpRouter.Use(middleware.Distribute()) + + // claude related routes + httpRouter.POST("/messages", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatClaude) + }) + + // chat related routes + httpRouter.POST("/completions", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAI) + }) + httpRouter.POST("/chat/completions", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAI) + }) + + // response related routes + httpRouter.POST("/responses", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIResponses) + }) + + // image related routes + httpRouter.POST("/edits", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIImage) + }) + httpRouter.POST("/images/generations", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIImage) + }) + httpRouter.POST("/images/edits", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIImage) + }) + + // embedding related routes + httpRouter.POST("/embeddings", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatEmbedding) + }) + + // audio related routes + httpRouter.POST("/audio/transcriptions", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIAudio) + }) + httpRouter.POST("/audio/translations", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIAudio) + }) + httpRouter.POST("/audio/speech", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIAudio) + }) + + // rerank related routes + httpRouter.POST("/rerank", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatRerank) + }) + + // gemini relay routes + httpRouter.POST("/engines/:model/embeddings", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatGemini) + }) + httpRouter.POST("/models/*path", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatGemini) + }) + + // other relay routes + httpRouter.POST("/moderations", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAI) + }) + + // not implemented + httpRouter.POST("/images/variations", controller.RelayNotImplemented) + httpRouter.GET("/files", controller.RelayNotImplemented) + httpRouter.POST("/files", controller.RelayNotImplemented) + httpRouter.DELETE("/files/:id", controller.RelayNotImplemented) + httpRouter.GET("/files/:id", controller.RelayNotImplemented) + httpRouter.GET("/files/:id/content", controller.RelayNotImplemented) + httpRouter.POST("/fine-tunes", controller.RelayNotImplemented) + httpRouter.GET("/fine-tunes", controller.RelayNotImplemented) + httpRouter.GET("/fine-tunes/:id", controller.RelayNotImplemented) + httpRouter.POST("/fine-tunes/:id/cancel", controller.RelayNotImplemented) + httpRouter.GET("/fine-tunes/:id/events", controller.RelayNotImplemented) + httpRouter.DELETE("/models/:model", controller.RelayNotImplemented) + } + + relayMjRouter := router.Group("/mj") + registerMjRouterGroup(relayMjRouter) + + relayMjModeRouter := router.Group("/:mode/mj") + registerMjRouterGroup(relayMjModeRouter) + //relayMjRouter.Use() + + relaySunoRouter := router.Group("/suno") + relaySunoRouter.Use(middleware.TokenAuth(), middleware.Distribute()) + { + relaySunoRouter.POST("/submit/:action", controller.RelayTask) + relaySunoRouter.POST("/fetch", controller.RelayTask) + relaySunoRouter.GET("/fetch/:id", controller.RelayTask) + } + + relayGeminiRouter := router.Group("/v1beta") + relayGeminiRouter.Use(middleware.TokenAuth()) + relayGeminiRouter.Use(middleware.ModelRequestRateLimit()) + relayGeminiRouter.Use(middleware.Distribute()) + { + // Gemini API 路径格式: /v1beta/models/{model_name}:{action} + relayGeminiRouter.POST("/models/*path", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatGemini) + }) + } +} + +func registerMjRouterGroup(relayMjRouter *gin.RouterGroup) { + relayMjRouter.GET("/image/:id", relay.RelayMidjourneyImage) + relayMjRouter.Use(middleware.TokenAuth(), middleware.Distribute()) + { + relayMjRouter.POST("/submit/action", controller.RelayMidjourney) + relayMjRouter.POST("/submit/shorten", controller.RelayMidjourney) + relayMjRouter.POST("/submit/modal", controller.RelayMidjourney) + relayMjRouter.POST("/submit/imagine", controller.RelayMidjourney) + relayMjRouter.POST("/submit/change", controller.RelayMidjourney) + relayMjRouter.POST("/submit/simple-change", controller.RelayMidjourney) + relayMjRouter.POST("/submit/describe", controller.RelayMidjourney) + relayMjRouter.POST("/submit/blend", controller.RelayMidjourney) + relayMjRouter.POST("/submit/edits", controller.RelayMidjourney) + relayMjRouter.POST("/submit/video", controller.RelayMidjourney) + relayMjRouter.POST("/notify", controller.RelayMidjourney) + relayMjRouter.GET("/task/:id/fetch", controller.RelayMidjourney) + relayMjRouter.GET("/task/:id/image-seed", controller.RelayMidjourney) + relayMjRouter.POST("/task/list-by-condition", controller.RelayMidjourney) + relayMjRouter.POST("/insight-face/swap", controller.RelayMidjourney) + relayMjRouter.POST("/submit/upload-discord-images", controller.RelayMidjourney) + } +} diff --git a/router/video-router.go b/router/video-router.go new file mode 100644 index 0000000000000000000000000000000000000000..72459fe182faf4b4640354c1bfa300aad673260c --- /dev/null +++ b/router/video-router.go @@ -0,0 +1,41 @@ +package router + +import ( + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/middleware" + + "github.com/gin-gonic/gin" +) + +func SetVideoRouter(router *gin.Engine) { + videoV1Router := router.Group("/v1") + videoV1Router.GET("/videos/:task_id/content", controller.VideoProxy) + videoV1Router.Use(middleware.TokenAuth(), middleware.Distribute()) + { + videoV1Router.POST("/video/generations", controller.RelayTask) + videoV1Router.GET("/video/generations/:task_id", controller.RelayTask) + } + // openai compatible API video routes + // docs: https://platform.openai.com/docs/api-reference/videos/create + { + videoV1Router.POST("/videos", controller.RelayTask) + videoV1Router.GET("/videos/:task_id", controller.RelayTask) + } + + klingV1Router := router.Group("/kling/v1") + klingV1Router.Use(middleware.KlingRequestConvert(), middleware.TokenAuth(), middleware.Distribute()) + { + klingV1Router.POST("/videos/text2video", controller.RelayTask) + klingV1Router.POST("/videos/image2video", controller.RelayTask) + klingV1Router.GET("/videos/text2video/:task_id", controller.RelayTask) + klingV1Router.GET("/videos/image2video/:task_id", controller.RelayTask) + } + + // Jimeng official API routes - direct mapping to official API format + jimengOfficialGroup := router.Group("jimeng") + jimengOfficialGroup.Use(middleware.JimengRequestConvert(), middleware.TokenAuth(), middleware.Distribute()) + { + // Maps to: /?Action=CVSync2AsyncSubmitTask&Version=2022-08-31 and /?Action=CVSync2AsyncGetResult&Version=2022-08-31 + jimengOfficialGroup.POST("/", controller.RelayTask) + } +} diff --git a/router/web-router.go b/router/web-router.go new file mode 100644 index 0000000000000000000000000000000000000000..b053a3e63d944523402ee9ead48f06d952f23474 --- /dev/null +++ b/router/web-router.go @@ -0,0 +1,29 @@ +package router + +import ( + "embed" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/middleware" + "github.com/gin-contrib/gzip" + "github.com/gin-contrib/static" + "github.com/gin-gonic/gin" +) + +func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { + router.Use(gzip.Gzip(gzip.DefaultCompression)) + router.Use(middleware.GlobalWebRateLimit()) + router.Use(middleware.Cache()) + router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/dist"))) + router.NoRoute(func(c *gin.Context) { + if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") { + controller.RelayNotFound(c) + return + } + c.Header("Cache-Control", "no-cache") + c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage) + }) +} diff --git a/service/audio.go b/service/audio.go new file mode 100644 index 0000000000000000000000000000000000000000..c4b6f01b2fafe2d137631c7a1f5510619bd389bc --- /dev/null +++ b/service/audio.go @@ -0,0 +1,48 @@ +package service + +import ( + "encoding/base64" + "fmt" + "strings" +) + +func parseAudio(audioBase64 string, format string) (duration float64, err error) { + audioData, err := base64.StdEncoding.DecodeString(audioBase64) + if err != nil { + return 0, fmt.Errorf("base64 decode error: %v", err) + } + + var samplesCount int + var sampleRate int + + switch format { + case "pcm16": + samplesCount = len(audioData) / 2 // 16位 = 2字节每样本 + sampleRate = 24000 // 24kHz + case "g711_ulaw", "g711_alaw": + samplesCount = len(audioData) // 8位 = 1字节每样本 + sampleRate = 8000 // 8kHz + default: + samplesCount = len(audioData) // 8位 = 1字节每样本 + sampleRate = 8000 // 8kHz + } + + duration = float64(samplesCount) / float64(sampleRate) + return duration, nil +} + +func DecodeBase64AudioData(audioBase64 string) (string, error) { + // 检查并移除 data:audio/xxx;base64, 前缀 + idx := strings.Index(audioBase64, ",") + if idx != -1 { + audioBase64 = audioBase64[idx+1:] + } + + // 解码 Base64 数据 + _, err := base64.StdEncoding.DecodeString(audioBase64) + if err != nil { + return "", fmt.Errorf("base64 decode error: %v", err) + } + + return audioBase64, nil +} diff --git a/service/channel.go b/service/channel.go new file mode 100644 index 0000000000000000000000000000000000000000..8f8a357261bec3ea4b41231140e78280d17f5caa --- /dev/null +++ b/service/channel.go @@ -0,0 +1,112 @@ +package service + +import ( + "fmt" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/types" +) + +func formatNotifyType(channelId int, status int) string { + return fmt.Sprintf("%s_%d_%d", dto.NotifyTypeChannelUpdate, channelId, status) +} + +// disable & notify +func DisableChannel(channelError types.ChannelError, reason string) { + common.SysLog(fmt.Sprintf("通道「%s」(#%d)发生错误,准备禁用,原因:%s", channelError.ChannelName, channelError.ChannelId, reason)) + + // 检查是否启用自动禁用功能 + if !channelError.AutoBan { + common.SysLog(fmt.Sprintf("通道「%s」(#%d)未启用自动禁用功能,跳过禁用操作", channelError.ChannelName, channelError.ChannelId)) + return + } + + success := model.UpdateChannelStatus(channelError.ChannelId, channelError.UsingKey, common.ChannelStatusAutoDisabled, reason) + if success { + subject := fmt.Sprintf("通道「%s」(#%d)已被禁用", channelError.ChannelName, channelError.ChannelId) + content := fmt.Sprintf("通道「%s」(#%d)已被禁用,原因:%s", channelError.ChannelName, channelError.ChannelId, reason) + NotifyRootUser(formatNotifyType(channelError.ChannelId, common.ChannelStatusAutoDisabled), subject, content) + } +} + +func EnableChannel(channelId int, usingKey string, channelName string) { + success := model.UpdateChannelStatus(channelId, usingKey, common.ChannelStatusEnabled, "") + if success { + subject := fmt.Sprintf("通道「%s」(#%d)已被启用", channelName, channelId) + content := fmt.Sprintf("通道「%s」(#%d)已被启用", channelName, channelId) + NotifyRootUser(formatNotifyType(channelId, common.ChannelStatusEnabled), subject, content) + } +} + +func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool { + if !common.AutomaticDisableChannelEnabled { + return false + } + if err == nil { + return false + } + if types.IsChannelError(err) { + return true + } + if types.IsSkipRetryError(err) { + return false + } + if err.StatusCode == http.StatusUnauthorized { + return true + } + if err.StatusCode == http.StatusForbidden { + switch channelType { + case constant.ChannelTypeGemini: + return true + } + } + oaiErr := err.ToOpenAIError() + switch oaiErr.Code { + case "invalid_api_key": + return true + case "account_deactivated": + return true + case "billing_not_active": + return true + case "pre_consume_token_quota_failed": + return true + case "Arrearage": + return true + } + switch oaiErr.Type { + case "insufficient_quota": + return true + case "insufficient_user_quota": + return true + // https://docs.anthropic.com/claude/reference/errors + case "authentication_error": + return true + case "permission_error": + return true + case "forbidden": + return true + } + + lowerMessage := strings.ToLower(err.Error()) + search, _ := AcSearch(lowerMessage, operation_setting.AutomaticDisableKeywords, true) + return search +} + +func ShouldEnableChannel(newAPIError *types.NewAPIError, status int) bool { + if !common.AutomaticEnableChannelEnabled { + return false + } + if newAPIError != nil { + return false + } + if status != common.ChannelStatusAutoDisabled { + return false + } + return true +} diff --git a/service/channel_select.go b/service/channel_select.go new file mode 100644 index 0000000000000000000000000000000000000000..53f7d2c2ae1edee796ac5ac7c41bcc141c151a5f --- /dev/null +++ b/service/channel_select.go @@ -0,0 +1,42 @@ +package service + +import ( + "errors" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting" + "github.com/gin-gonic/gin" +) + +func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, modelName string, retry int) (*model.Channel, string, error) { + var channel *model.Channel + var err error + selectGroup := group + userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) + if group == "auto" { + if len(setting.GetAutoGroups()) == 0 { + return nil, selectGroup, errors.New("auto groups is not enabled") + } + for _, autoGroup := range GetUserAutoGroup(userGroup) { + logger.LogDebug(c, "Auto selecting group:", autoGroup) + channel, _ = model.GetRandomSatisfiedChannel(autoGroup, modelName, retry) + if channel == nil { + continue + } else { + c.Set("auto_group", autoGroup) + selectGroup = autoGroup + logger.LogDebug(c, "Auto selected group:", autoGroup) + break + } + } + } else { + channel, err = model.GetRandomSatisfiedChannel(group, modelName, retry) + if err != nil { + return nil, group, err + } + } + return channel, selectGroup, nil +} diff --git a/service/convert.go b/service/convert.go new file mode 100644 index 0000000000000000000000000000000000000000..975ab2d0b8a434782215e1e1329cb9e65d0ccaa6 --- /dev/null +++ b/service/convert.go @@ -0,0 +1,810 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel/openrouter" + relaycommon "github.com/QuantumNous/new-api/relay/common" +) + +func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) { + openAIRequest := dto.GeneralOpenAIRequest{ + Model: claudeRequest.Model, + MaxTokens: claudeRequest.MaxTokens, + Temperature: claudeRequest.Temperature, + TopP: claudeRequest.TopP, + Stream: claudeRequest.Stream, + } + + isOpenRouter := info.ChannelType == constant.ChannelTypeOpenRouter + + if claudeRequest.Thinking != nil && claudeRequest.Thinking.Type == "enabled" { + if isOpenRouter { + reasoning := openrouter.RequestReasoning{ + MaxTokens: claudeRequest.Thinking.GetBudgetTokens(), + } + reasoningJSON, err := json.Marshal(reasoning) + if err != nil { + return nil, fmt.Errorf("failed to marshal reasoning: %w", err) + } + openAIRequest.Reasoning = reasoningJSON + } else { + thinkingSuffix := "-thinking" + if strings.HasSuffix(info.OriginModelName, thinkingSuffix) && + !strings.HasSuffix(openAIRequest.Model, thinkingSuffix) { + openAIRequest.Model = openAIRequest.Model + thinkingSuffix + } + } + } + + // Convert stop sequences + if len(claudeRequest.StopSequences) == 1 { + openAIRequest.Stop = claudeRequest.StopSequences[0] + } else if len(claudeRequest.StopSequences) > 1 { + openAIRequest.Stop = claudeRequest.StopSequences + } + + // Convert tools + tools, _ := common.Any2Type[[]dto.Tool](claudeRequest.Tools) + openAITools := make([]dto.ToolCallRequest, 0) + for _, claudeTool := range tools { + openAITool := dto.ToolCallRequest{ + Type: "function", + Function: dto.FunctionRequest{ + Name: claudeTool.Name, + Description: claudeTool.Description, + Parameters: claudeTool.InputSchema, + }, + } + openAITools = append(openAITools, openAITool) + } + openAIRequest.Tools = openAITools + + // Convert messages + openAIMessages := make([]dto.Message, 0) + + // Add system message if present + if claudeRequest.System != nil { + if claudeRequest.IsStringSystem() && claudeRequest.GetStringSystem() != "" { + openAIMessage := dto.Message{ + Role: "system", + } + openAIMessage.SetStringContent(claudeRequest.GetStringSystem()) + openAIMessages = append(openAIMessages, openAIMessage) + } else { + systems := claudeRequest.ParseSystem() + if len(systems) > 0 { + openAIMessage := dto.Message{ + Role: "system", + } + isOpenRouterClaude := isOpenRouter && strings.HasPrefix(info.UpstreamModelName, "anthropic/claude") + if isOpenRouterClaude { + systemMediaMessages := make([]dto.MediaContent, 0, len(systems)) + for _, system := range systems { + message := dto.MediaContent{ + Type: "text", + Text: system.GetText(), + CacheControl: system.CacheControl, + } + systemMediaMessages = append(systemMediaMessages, message) + } + openAIMessage.SetMediaContent(systemMediaMessages) + } else { + systemStr := "" + for _, system := range systems { + if system.Text != nil { + systemStr += *system.Text + } + } + openAIMessage.SetStringContent(systemStr) + } + openAIMessages = append(openAIMessages, openAIMessage) + } + } + } + for _, claudeMessage := range claudeRequest.Messages { + openAIMessage := dto.Message{ + Role: claudeMessage.Role, + } + + //log.Printf("claudeMessage.Content: %v", claudeMessage.Content) + if claudeMessage.IsStringContent() { + openAIMessage.SetStringContent(claudeMessage.GetStringContent()) + } else { + content, err := claudeMessage.ParseContent() + if err != nil { + return nil, err + } + contents := content + var toolCalls []dto.ToolCallRequest + mediaMessages := make([]dto.MediaContent, 0, len(contents)) + + for _, mediaMsg := range contents { + switch mediaMsg.Type { + case "text": + message := dto.MediaContent{ + Type: "text", + Text: mediaMsg.GetText(), + CacheControl: mediaMsg.CacheControl, + } + mediaMessages = append(mediaMessages, message) + case "image": + // Handle image conversion (base64 to URL or keep as is) + imageData := fmt.Sprintf("data:%s;base64,%s", mediaMsg.Source.MediaType, mediaMsg.Source.Data) + //textContent += fmt.Sprintf("[Image: %s]", imageData) + mediaMessage := dto.MediaContent{ + Type: "image_url", + ImageUrl: &dto.MessageImageUrl{Url: imageData}, + } + mediaMessages = append(mediaMessages, mediaMessage) + case "tool_use": + toolCall := dto.ToolCallRequest{ + ID: mediaMsg.Id, + Type: "function", + Function: dto.FunctionRequest{ + Name: mediaMsg.Name, + Arguments: toJSONString(mediaMsg.Input), + }, + } + toolCalls = append(toolCalls, toolCall) + case "tool_result": + // Add tool result as a separate message + toolName := mediaMsg.Name + if toolName == "" { + toolName = claudeRequest.SearchToolNameByToolCallId(mediaMsg.ToolUseId) + } + oaiToolMessage := dto.Message{ + Role: "tool", + Name: &toolName, + ToolCallId: mediaMsg.ToolUseId, + } + //oaiToolMessage.SetStringContent(*mediaMsg.GetMediaContent().Text) + if mediaMsg.IsStringContent() { + oaiToolMessage.SetStringContent(mediaMsg.GetStringContent()) + } else { + mediaContents := mediaMsg.ParseMediaContent() + encodeJson, _ := common.Marshal(mediaContents) + oaiToolMessage.SetStringContent(string(encodeJson)) + } + openAIMessages = append(openAIMessages, oaiToolMessage) + } + } + + if len(toolCalls) > 0 { + openAIMessage.SetToolCalls(toolCalls) + } + + if len(mediaMessages) > 0 && len(toolCalls) == 0 { + openAIMessage.SetMediaContent(mediaMessages) + } + } + if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 { + openAIMessages = append(openAIMessages, openAIMessage) + } + } + + openAIRequest.Messages = openAIMessages + + return &openAIRequest, nil +} + +func generateStopBlock(index int) *dto.ClaudeResponse { + return &dto.ClaudeResponse{ + Type: "content_block_stop", + Index: common.GetPointer[int](index), + } +} + +func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse { + var claudeResponses []*dto.ClaudeResponse + if info.SendResponseCount == 1 { + msg := &dto.ClaudeMediaMessage{ + Id: openAIResponse.Id, + Model: openAIResponse.Model, + Type: "message", + Role: "assistant", + Usage: &dto.ClaudeUsage{ + InputTokens: info.PromptTokens, + OutputTokens: 0, + }, + } + msg.SetContent(make([]any, 0)) + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Type: "message_start", + Message: msg, + }) + claudeResponses = append(claudeResponses) + //claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + // Type: "ping", + //}) + if openAIResponse.IsToolCall() { + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools + resp := &dto.ClaudeResponse{ + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Id: openAIResponse.GetFirstToolCall().ID, + Type: "tool_use", + Name: openAIResponse.GetFirstToolCall().Function.Name, + Input: map[string]interface{}{}, + }, + } + resp.SetIndex(0) + claudeResponses = append(claudeResponses, resp) + } else { + + } + // 判断首个响应是否存在内容(非标准的 OpenAI 响应) + if len(openAIResponse.Choices) > 0 && len(openAIResponse.Choices[0].Delta.GetContentString()) > 0 { + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](""), + }, + }) + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_delta", + Delta: &dto.ClaudeMediaMessage{ + Type: "text_delta", + Text: common.GetPointer[string](openAIResponse.Choices[0].Delta.GetContentString()), + }, + }) + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText + } + return claudeResponses + } + + if len(openAIResponse.Choices) == 0 { + // no choices + // 可能为非标准的 OpenAI 响应,判断是否已经完成 + if info.Done { + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + oaiUsage := info.ClaudeConvertInfo.Usage + if oaiUsage != nil { + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Type: "message_delta", + Usage: &dto.ClaudeUsage{ + InputTokens: oaiUsage.PromptTokens, + OutputTokens: oaiUsage.CompletionTokens, + CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens, + CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens, + }, + Delta: &dto.ClaudeMediaMessage{ + StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)), + }, + }) + } + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Type: "message_stop", + }) + } + return claudeResponses + } else { + chosenChoice := openAIResponse.Choices[0] + if chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != "" { + // should be done + info.FinishReason = *chosenChoice.FinishReason + if !info.Done { + return claudeResponses + } + } + if info.Done { + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + oaiUsage := info.ClaudeConvertInfo.Usage + if oaiUsage != nil { + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Type: "message_delta", + Usage: &dto.ClaudeUsage{ + InputTokens: oaiUsage.PromptTokens, + OutputTokens: oaiUsage.CompletionTokens, + CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens, + CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens, + }, + Delta: &dto.ClaudeMediaMessage{ + StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)), + }, + }) + } + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Type: "message_stop", + }) + } else { + var claudeResponse dto.ClaudeResponse + var isEmpty bool + claudeResponse.Type = "content_block_delta" + if len(chosenChoice.Delta.ToolCalls) > 0 { + if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeTools { + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + info.ClaudeConvertInfo.Index++ + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Id: openAIResponse.GetFirstToolCall().ID, + Type: "tool_use", + Name: openAIResponse.GetFirstToolCall().Function.Name, + Input: map[string]interface{}{}, + }, + }) + } + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools + // tools delta + claudeResponse.Delta = &dto.ClaudeMediaMessage{ + Type: "input_json_delta", + PartialJson: &chosenChoice.Delta.ToolCalls[0].Function.Arguments, + } + } else { + reasoning := chosenChoice.Delta.GetReasoningContent() + textContent := chosenChoice.Delta.GetContentString() + if reasoning != "" || textContent != "" { + if reasoning != "" { + if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking { + //info.ClaudeConvertInfo.Index++ + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Type: "thinking", + Thinking: common.GetPointer[string](""), + }, + }) + } + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking + // text delta + claudeResponse.Delta = &dto.ClaudeMediaMessage{ + Type: "thinking_delta", + Thinking: &reasoning, + } + } else { + if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText { + if info.LastMessagesType == relaycommon.LastMessageTypeThinking || info.LastMessagesType == relaycommon.LastMessageTypeTools { + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + info.ClaudeConvertInfo.Index++ + } + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](""), + }, + }) + } + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText + // text delta + claudeResponse.Delta = &dto.ClaudeMediaMessage{ + Type: "text_delta", + Text: common.GetPointer[string](textContent), + } + } + } else { + isEmpty = true + } + } + claudeResponse.Index = &info.ClaudeConvertInfo.Index + if !isEmpty { + claudeResponses = append(claudeResponses, &claudeResponse) + } + } + } + + return claudeResponses +} + +func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.ClaudeResponse { + var stopReason string + contents := make([]dto.ClaudeMediaMessage, 0) + claudeResponse := &dto.ClaudeResponse{ + Id: openAIResponse.Id, + Type: "message", + Role: "assistant", + Model: openAIResponse.Model, + } + for _, choice := range openAIResponse.Choices { + stopReason = stopReasonOpenAI2Claude(choice.FinishReason) + if choice.FinishReason == "tool_calls" { + for _, toolUse := range choice.Message.ParseToolCalls() { + claudeContent := dto.ClaudeMediaMessage{} + claudeContent.Type = "tool_use" + claudeContent.Id = toolUse.ID + claudeContent.Name = toolUse.Function.Name + var mapParams map[string]interface{} + if err := common.Unmarshal([]byte(toolUse.Function.Arguments), &mapParams); err == nil { + claudeContent.Input = mapParams + } else { + claudeContent.Input = toolUse.Function.Arguments + } + contents = append(contents, claudeContent) + } + } else { + claudeContent := dto.ClaudeMediaMessage{} + claudeContent.Type = "text" + claudeContent.SetText(choice.Message.StringContent()) + contents = append(contents, claudeContent) + } + } + claudeResponse.Content = contents + claudeResponse.StopReason = stopReason + claudeResponse.Usage = &dto.ClaudeUsage{ + InputTokens: openAIResponse.PromptTokens, + OutputTokens: openAIResponse.CompletionTokens, + } + + return claudeResponse +} + +func stopReasonOpenAI2Claude(reason string) string { + switch reason { + case "stop": + return "end_turn" + case "stop_sequence": + return "stop_sequence" + case "length": + fallthrough + case "max_tokens": + return "max_tokens" + case "tool_calls": + return "tool_use" + default: + return reason + } +} + +func toJSONString(v interface{}) string { + b, err := json.Marshal(v) + if err != nil { + return "{}" + } + return string(b) +} + +func GeminiToOpenAIRequest(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) { + openaiRequest := &dto.GeneralOpenAIRequest{ + Model: info.UpstreamModelName, + Stream: info.IsStream, + } + + // 转换 messages + var messages []dto.Message + for _, content := range geminiRequest.Contents { + message := dto.Message{ + Role: convertGeminiRoleToOpenAI(content.Role), + } + + // 处理 parts + var mediaContents []dto.MediaContent + var toolCalls []dto.ToolCallRequest + for _, part := range content.Parts { + if part.Text != "" { + mediaContent := dto.MediaContent{ + Type: "text", + Text: part.Text, + } + mediaContents = append(mediaContents, mediaContent) + } else if part.InlineData != nil { + mediaContent := dto.MediaContent{ + Type: "image_url", + ImageUrl: &dto.MessageImageUrl{ + Url: fmt.Sprintf("data:%s;base64,%s", part.InlineData.MimeType, part.InlineData.Data), + Detail: "auto", + MimeType: part.InlineData.MimeType, + }, + } + mediaContents = append(mediaContents, mediaContent) + } else if part.FileData != nil { + mediaContent := dto.MediaContent{ + Type: "image_url", + ImageUrl: &dto.MessageImageUrl{ + Url: part.FileData.FileUri, + Detail: "auto", + MimeType: part.FileData.MimeType, + }, + } + mediaContents = append(mediaContents, mediaContent) + } else if part.FunctionCall != nil { + // 处理 Gemini 的工具调用 + toolCall := dto.ToolCallRequest{ + ID: fmt.Sprintf("call_%d", len(toolCalls)+1), // 生成唯一ID + Type: "function", + Function: dto.FunctionRequest{ + Name: part.FunctionCall.FunctionName, + Arguments: toJSONString(part.FunctionCall.Arguments), + }, + } + toolCalls = append(toolCalls, toolCall) + } else if part.FunctionResponse != nil { + // 处理 Gemini 的工具响应,创建单独的 tool 消息 + toolMessage := dto.Message{ + Role: "tool", + ToolCallId: fmt.Sprintf("call_%d", len(toolCalls)), // 使用对应的调用ID + } + toolMessage.SetStringContent(toJSONString(part.FunctionResponse.Response)) + messages = append(messages, toolMessage) + } + } + + // 设置消息内容 + if len(toolCalls) > 0 { + // 如果有工具调用,设置工具调用 + message.SetToolCalls(toolCalls) + } else if len(mediaContents) == 1 && mediaContents[0].Type == "text" { + // 如果只有一个文本内容,直接设置字符串 + message.Content = mediaContents[0].Text + } else if len(mediaContents) > 0 { + // 如果有多个内容或包含媒体,设置为数组 + message.SetMediaContent(mediaContents) + } + + // 只有当消息有内容或工具调用时才添加 + if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 { + messages = append(messages, message) + } + } + + openaiRequest.Messages = messages + + if geminiRequest.GenerationConfig.Temperature != nil { + openaiRequest.Temperature = geminiRequest.GenerationConfig.Temperature + } + if geminiRequest.GenerationConfig.TopP > 0 { + openaiRequest.TopP = geminiRequest.GenerationConfig.TopP + } + if geminiRequest.GenerationConfig.TopK > 0 { + openaiRequest.TopK = int(geminiRequest.GenerationConfig.TopK) + } + if geminiRequest.GenerationConfig.MaxOutputTokens > 0 { + openaiRequest.MaxTokens = geminiRequest.GenerationConfig.MaxOutputTokens + } + // gemini stop sequences 最多 5 个,openai stop 最多 4 个 + if len(geminiRequest.GenerationConfig.StopSequences) > 0 { + openaiRequest.Stop = geminiRequest.GenerationConfig.StopSequences[:4] + } + if geminiRequest.GenerationConfig.CandidateCount > 0 { + openaiRequest.N = geminiRequest.GenerationConfig.CandidateCount + } + + // 转换工具调用 + if len(geminiRequest.GetTools()) > 0 { + var tools []dto.ToolCallRequest + for _, tool := range geminiRequest.GetTools() { + if tool.FunctionDeclarations != nil { + // 将 Gemini 的 FunctionDeclarations 转换为 OpenAI 的 ToolCallRequest + functionDeclarations, ok := tool.FunctionDeclarations.([]dto.FunctionRequest) + if ok { + for _, function := range functionDeclarations { + openAITool := dto.ToolCallRequest{ + Type: "function", + Function: dto.FunctionRequest{ + Name: function.Name, + Description: function.Description, + Parameters: function.Parameters, + }, + } + tools = append(tools, openAITool) + } + } + } + } + if len(tools) > 0 { + openaiRequest.Tools = tools + } + } + + // gemini system instructions + if geminiRequest.SystemInstructions != nil { + // 将系统指令作为第一条消息插入 + systemMessage := dto.Message{ + Role: "system", + Content: extractTextFromGeminiParts(geminiRequest.SystemInstructions.Parts), + } + openaiRequest.Messages = append([]dto.Message{systemMessage}, openaiRequest.Messages...) + } + + return openaiRequest, nil +} + +func convertGeminiRoleToOpenAI(geminiRole string) string { + switch geminiRole { + case "user": + return "user" + case "model": + return "assistant" + case "function": + return "function" + default: + return "user" + } +} + +func extractTextFromGeminiParts(parts []dto.GeminiPart) string { + var texts []string + for _, part := range parts { + if part.Text != "" { + texts = append(texts, part.Text) + } + } + return strings.Join(texts, "\n") +} + +// ResponseOpenAI2Gemini 将 OpenAI 响应转换为 Gemini 格式 +func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse { + geminiResponse := &dto.GeminiChatResponse{ + Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)), + UsageMetadata: dto.GeminiUsageMetadata{ + PromptTokenCount: openAIResponse.PromptTokens, + CandidatesTokenCount: openAIResponse.CompletionTokens, + TotalTokenCount: openAIResponse.PromptTokens + openAIResponse.CompletionTokens, + }, + } + + for _, choice := range openAIResponse.Choices { + candidate := dto.GeminiChatCandidate{ + Index: int64(choice.Index), + SafetyRatings: []dto.GeminiChatSafetyRating{}, + } + + // 设置结束原因 + var finishReason string + switch choice.FinishReason { + case "stop": + finishReason = "STOP" + case "length": + finishReason = "MAX_TOKENS" + case "content_filter": + finishReason = "SAFETY" + case "tool_calls": + finishReason = "STOP" + default: + finishReason = "STOP" + } + candidate.FinishReason = &finishReason + + // 转换消息内容 + content := dto.GeminiChatContent{ + Role: "model", + Parts: make([]dto.GeminiPart, 0), + } + + // 处理工具调用 + toolCalls := choice.Message.ParseToolCalls() + if len(toolCalls) > 0 { + for _, toolCall := range toolCalls { + // 解析参数 + var args map[string]interface{} + if toolCall.Function.Arguments != "" { + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { + args = map[string]interface{}{"arguments": toolCall.Function.Arguments} + } + } else { + args = make(map[string]interface{}) + } + + part := dto.GeminiPart{ + FunctionCall: &dto.FunctionCall{ + FunctionName: toolCall.Function.Name, + Arguments: args, + }, + } + content.Parts = append(content.Parts, part) + } + } else { + // 处理文本内容 + textContent := choice.Message.StringContent() + if textContent != "" { + part := dto.GeminiPart{ + Text: textContent, + } + content.Parts = append(content.Parts, part) + } + } + + candidate.Content = content + geminiResponse.Candidates = append(geminiResponse.Candidates, candidate) + } + + return geminiResponse +} + +// StreamResponseOpenAI2Gemini 将 OpenAI 流式响应转换为 Gemini 格式 +func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse { + // 检查是否有实际内容或结束标志 + hasContent := false + hasFinishReason := false + for _, choice := range openAIResponse.Choices { + if len(choice.Delta.GetContentString()) > 0 || (choice.Delta.ToolCalls != nil && len(choice.Delta.ToolCalls) > 0) { + hasContent = true + } + if choice.FinishReason != nil { + hasFinishReason = true + } + } + + // 如果没有实际内容且没有结束标志,跳过。主要针对 openai 流响应开头的空数据 + if !hasContent && !hasFinishReason { + return nil + } + + geminiResponse := &dto.GeminiChatResponse{ + Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)), + UsageMetadata: dto.GeminiUsageMetadata{ + PromptTokenCount: info.PromptTokens, + CandidatesTokenCount: 0, // 流式响应中可能没有完整的 usage 信息 + TotalTokenCount: info.PromptTokens, + }, + } + + for _, choice := range openAIResponse.Choices { + candidate := dto.GeminiChatCandidate{ + Index: int64(choice.Index), + SafetyRatings: []dto.GeminiChatSafetyRating{}, + } + + // 设置结束原因 + if choice.FinishReason != nil { + var finishReason string + switch *choice.FinishReason { + case "stop": + finishReason = "STOP" + case "length": + finishReason = "MAX_TOKENS" + case "content_filter": + finishReason = "SAFETY" + case "tool_calls": + finishReason = "STOP" + default: + finishReason = "STOP" + } + candidate.FinishReason = &finishReason + } + + // 转换消息内容 + content := dto.GeminiChatContent{ + Role: "model", + Parts: make([]dto.GeminiPart, 0), + } + + // 处理工具调用 + if choice.Delta.ToolCalls != nil { + for _, toolCall := range choice.Delta.ToolCalls { + // 解析参数 + var args map[string]interface{} + if toolCall.Function.Arguments != "" { + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { + args = map[string]interface{}{"arguments": toolCall.Function.Arguments} + } + } else { + args = make(map[string]interface{}) + } + + part := dto.GeminiPart{ + FunctionCall: &dto.FunctionCall{ + FunctionName: toolCall.Function.Name, + Arguments: args, + }, + } + content.Parts = append(content.Parts, part) + } + } else { + // 处理文本内容 + textContent := choice.Delta.GetContentString() + if textContent != "" { + part := dto.GeminiPart{ + Text: textContent, + } + content.Parts = append(content.Parts, part) + } + } + + candidate.Content = content + geminiResponse.Candidates = append(geminiResponse.Candidates, candidate) + } + + return geminiResponse +} diff --git a/service/download.go b/service/download.go new file mode 100644 index 0000000000000000000000000000000000000000..752d8c65b6dbd5776b0daa771c3bf0ac0eea5d15 --- /dev/null +++ b/service/download.go @@ -0,0 +1,70 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/system_setting" +) + +// WorkerRequest Worker请求的数据结构 +type WorkerRequest struct { + URL string `json:"url"` + Key string `json:"key"` + Method string `json:"method,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body json.RawMessage `json:"body,omitempty"` +} + +// DoWorkerRequest 通过Worker发送请求 +func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { + if !system_setting.EnableWorker() { + return nil, fmt.Errorf("worker not enabled") + } + if !system_setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") { + return nil, fmt.Errorf("only support https url") + } + + // SSRF防护:验证请求URL + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return nil, fmt.Errorf("request reject: %v", err) + } + + workerUrl := system_setting.WorkerUrl + if !strings.HasSuffix(workerUrl, "/") { + workerUrl += "/" + } + + // 序列化worker请求数据 + workerPayload, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal worker payload: %v", err) + } + + return GetHttpClient().Post(workerUrl, "application/json", bytes.NewBuffer(workerPayload)) +} + +func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, err error) { + if system_setting.EnableWorker() { + common.SysLog(fmt.Sprintf("downloading file from worker: %s, reason: %s", originUrl, strings.Join(reason, ", "))) + req := &WorkerRequest{ + URL: originUrl, + Key: system_setting.WorkerValidKey, + } + return DoWorkerRequest(req) + } else { + // SSRF防护:验证请求URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return nil, fmt.Errorf("request reject: %v", err) + } + + common.SysLog(fmt.Sprintf("downloading from origin: %s, reason: %s", common.MaskSensitiveInfo(originUrl), strings.Join(reason, ", "))) + return GetHttpClient().Get(originUrl) + } +} diff --git a/service/epay.go b/service/epay.go new file mode 100644 index 0000000000000000000000000000000000000000..bfe14371eb9683849d2eb40ef206159674103bd2 --- /dev/null +++ b/service/epay.go @@ -0,0 +1,13 @@ +package service + +import ( + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/system_setting" +) + +func GetCallbackAddress() string { + if operation_setting.CustomCallbackAddress == "" { + return system_setting.ServerAddress + } + return operation_setting.CustomCallbackAddress +} diff --git a/service/error.go b/service/error.go new file mode 100644 index 0000000000000000000000000000000000000000..070335ec6ac700370137e4a32547bbf24f2ae569 --- /dev/null +++ b/service/error.go @@ -0,0 +1,157 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/types" +) + +func MidjourneyErrorWrapper(code int, desc string) *dto.MidjourneyResponse { + return &dto.MidjourneyResponse{ + Code: code, + Description: desc, + } +} + +func MidjourneyErrorWithStatusCodeWrapper(code int, desc string, statusCode int) *dto.MidjourneyResponseWithStatusCode { + return &dto.MidjourneyResponseWithStatusCode{ + StatusCode: statusCode, + Response: *MidjourneyErrorWrapper(code, desc), + } +} + +//// OpenAIErrorWrapper wraps an error into an OpenAIErrorWithStatusCode +//func OpenAIErrorWrapper(err error, code string, statusCode int) *dto.OpenAIErrorWithStatusCode { +// text := err.Error() +// lowerText := strings.ToLower(text) +// if !strings.HasPrefix(lowerText, "get file base64 from url") && !strings.HasPrefix(lowerText, "mime type is not supported") { +// if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") { +// common.SysLog(fmt.Sprintf("error: %s", text)) +// text = "请求上游地址失败" +// } +// } +// openAIError := dto.OpenAIError{ +// Message: text, +// Type: "new_api_error", +// Code: code, +// } +// return &dto.OpenAIErrorWithStatusCode{ +// Error: openAIError, +// StatusCode: statusCode, +// } +//} +// +//func OpenAIErrorWrapperLocal(err error, code string, statusCode int) *dto.OpenAIErrorWithStatusCode { +// openaiErr := OpenAIErrorWrapper(err, code, statusCode) +// openaiErr.LocalError = true +// return openaiErr +//} + +func ClaudeErrorWrapper(err error, code string, statusCode int) *dto.ClaudeErrorWithStatusCode { + text := err.Error() + lowerText := strings.ToLower(text) + if !strings.HasPrefix(lowerText, "get file base64 from url") { + if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") { + common.SysLog(fmt.Sprintf("error: %s", text)) + text = "请求上游地址失败" + } + } + claudeError := types.ClaudeError{ + Message: text, + Type: "new_api_error", + } + return &dto.ClaudeErrorWithStatusCode{ + Error: claudeError, + StatusCode: statusCode, + } +} + +func ClaudeErrorWrapperLocal(err error, code string, statusCode int) *dto.ClaudeErrorWithStatusCode { + claudeErr := ClaudeErrorWrapper(err, code, statusCode) + claudeErr.LocalError = true + return claudeErr +} + +func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) { + newApiErr = types.InitOpenAIError(types.ErrorCodeBadResponseStatusCode, resp.StatusCode) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return + } + CloseResponseBodyGracefully(resp) + var errResponse dto.GeneralErrorResponse + + err = common.Unmarshal(responseBody, &errResponse) + if err != nil { + if showBodyWhenFail { + newApiErr.Err = fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)) + } else { + if common.DebugEnabled { + logger.LogInfo(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))) + } + newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode) + } + return + } + if errResponse.Error.Message != "" { + // General format error (OpenAI, Anthropic, Gemini, etc.) + newApiErr = types.WithOpenAIError(errResponse.Error, resp.StatusCode) + } else { + newApiErr = types.NewOpenAIError(errors.New(errResponse.ToMessage()), types.ErrorCodeBadResponseStatusCode, resp.StatusCode) + } + return +} + +func ResetStatusCode(newApiErr *types.NewAPIError, statusCodeMappingStr string) { + if statusCodeMappingStr == "" || statusCodeMappingStr == "{}" { + return + } + statusCodeMapping := make(map[string]string) + err := common.Unmarshal([]byte(statusCodeMappingStr), &statusCodeMapping) + if err != nil { + return + } + if newApiErr.StatusCode == http.StatusOK { + return + } + codeStr := strconv.Itoa(newApiErr.StatusCode) + if _, ok := statusCodeMapping[codeStr]; ok { + intCode, _ := strconv.Atoi(statusCodeMapping[codeStr]) + newApiErr.StatusCode = intCode + } +} + +func TaskErrorWrapperLocal(err error, code string, statusCode int) *dto.TaskError { + openaiErr := TaskErrorWrapper(err, code, statusCode) + openaiErr.LocalError = true + return openaiErr +} + +func TaskErrorWrapper(err error, code string, statusCode int) *dto.TaskError { + text := err.Error() + lowerText := strings.ToLower(text) + if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") { + common.SysLog(fmt.Sprintf("error: %s", text)) + //text = "请求上游地址失败" + text = common.MaskSensitiveInfo(text) + } + //避免暴露内部错误 + taskError := &dto.TaskError{ + Code: code, + Message: text, + StatusCode: statusCode, + Error: err, + } + + return taskError +} diff --git a/service/file_decoder.go b/service/file_decoder.go new file mode 100644 index 0000000000000000000000000000000000000000..4ca139794a2f05d1a14ff0e42d5a05a6734f5f64 --- /dev/null +++ b/service/file_decoder.go @@ -0,0 +1,266 @@ +package service + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +// GetFileTypeFromUrl 获取文件类型,返回 mime type, 例如 image/jpeg, image/png, image/gif, image/bmp, image/tiff, application/pdf +// 如果获取失败,返回 application/octet-stream +func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, error) { + response, err := DoDownloadRequest(url, []string{"get_mime_type", strings.Join(reason, ", ")}...) + if err != nil { + common.SysLog(fmt.Sprintf("fail to get file type from url: %s, error: %s", url, err.Error())) + return "", err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + logger.LogError(c, fmt.Sprintf("failed to download file from %s, status code: %d", url, response.StatusCode)) + return "", fmt.Errorf("failed to download file, status code: %d", response.StatusCode) + } + + if headerType := strings.TrimSpace(response.Header.Get("Content-Type")); headerType != "" { + if i := strings.Index(headerType, ";"); i != -1 { + headerType = headerType[:i] + } + if headerType != "application/octet-stream" { + return headerType, nil + } + } + + if cd := response.Header.Get("Content-Disposition"); cd != "" { + parts := strings.Split(cd, ";") + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.HasPrefix(strings.ToLower(part), "filename=") { + name := strings.TrimSpace(strings.TrimPrefix(part, "filename=")) + if len(name) > 2 && name[0] == '"' && name[len(name)-1] == '"' { + name = name[1 : len(name)-1] + } + if dot := strings.LastIndex(name, "."); dot != -1 && dot+1 < len(name) { + ext := strings.ToLower(name[dot+1:]) + if ext != "" { + mt := GetMimeTypeByExtension(ext) + if mt != "application/octet-stream" { + return mt, nil + } + } + } + break + } + } + } + + cleanedURL := url + if q := strings.Index(cleanedURL, "?"); q != -1 { + cleanedURL = cleanedURL[:q] + } + if slash := strings.LastIndex(cleanedURL, "/"); slash != -1 && slash+1 < len(cleanedURL) { + last := cleanedURL[slash+1:] + if dot := strings.LastIndex(last, "."); dot != -1 && dot+1 < len(last) { + ext := strings.ToLower(last[dot+1:]) + if ext != "" { + mt := GetMimeTypeByExtension(ext) + if mt != "application/octet-stream" { + return mt, nil + } + } + } + } + + var readData []byte + limits := []int{512, 8 * 1024, 24 * 1024, 64 * 1024} + for _, limit := range limits { + logger.LogDebug(c, fmt.Sprintf("Trying to read %d bytes to determine file type", limit)) + if len(readData) < limit { + need := limit - len(readData) + tmp := make([]byte, need) + n, _ := io.ReadFull(response.Body, tmp) + if n > 0 { + readData = append(readData, tmp[:n]...) + } + } + + if len(readData) == 0 { + continue + } + + sniffed := http.DetectContentType(readData) + if sniffed != "" && sniffed != "application/octet-stream" { + return sniffed, nil + } + + if _, format, err := image.DecodeConfig(bytes.NewReader(readData)); err == nil { + switch strings.ToLower(format) { + case "jpeg", "jpg": + return "image/jpeg", nil + case "png": + return "image/png", nil + case "gif": + return "image/gif", nil + case "bmp": + return "image/bmp", nil + case "tiff": + return "image/tiff", nil + default: + if format != "" { + return "image/" + strings.ToLower(format), nil + } + } + } + } + + // Fallback + return "application/octet-stream", nil +} + +func GetFileBase64FromUrl(c *gin.Context, url string, reason ...string) (*types.LocalFileData, error) { + contextKey := fmt.Sprintf("file_download_%s", common.GenerateHMAC(url)) + + // Check if the file has already been downloaded in this request + if cachedData, exists := c.Get(contextKey); exists { + if common.DebugEnabled { + logger.LogDebug(c, fmt.Sprintf("Using cached file data for URL: %s", url)) + } + return cachedData.(*types.LocalFileData), nil + } + + var maxFileSize = constant.MaxFileDownloadMB * 1024 * 1024 + + resp, err := DoDownloadRequest(url, reason...) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Always use LimitReader to prevent oversized downloads + fileBytes, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxFileSize+1))) + if err != nil { + return nil, err + } + // Check actual size after reading + if len(fileBytes) > maxFileSize { + return nil, fmt.Errorf("file size exceeds maximum allowed size: %dMB", constant.MaxFileDownloadMB) + } + + // Convert to base64 + base64Data := base64.StdEncoding.EncodeToString(fileBytes) + + mimeType := resp.Header.Get("Content-Type") + if len(strings.Split(mimeType, ";")) > 1 { + // If Content-Type has parameters, take the first part + mimeType = strings.Split(mimeType, ";")[0] + } + if mimeType == "application/octet-stream" { + logger.LogDebug(c, fmt.Sprintf("MIME type is application/octet-stream for URL: %s", url)) + // try to guess the MIME type from the url last segment + urlParts := strings.Split(url, "/") + if len(urlParts) > 0 { + lastSegment := urlParts[len(urlParts)-1] + if strings.Contains(lastSegment, ".") { + // Extract the file extension + filename := strings.Split(lastSegment, ".") + if len(filename) > 1 { + ext := strings.ToLower(filename[len(filename)-1]) + // Guess MIME type based on file extension + mimeType = GetMimeTypeByExtension(ext) + } + } + } else { + // try to guess the MIME type from the file extension + fileName := resp.Header.Get("Content-Disposition") + if fileName != "" { + // Extract the filename from the Content-Disposition header + parts := strings.Split(fileName, ";") + for _, part := range parts { + if strings.HasPrefix(strings.TrimSpace(part), "filename=") { + fileName = strings.TrimSpace(strings.TrimPrefix(part, "filename=")) + // Remove quotes if present + if len(fileName) > 2 && fileName[0] == '"' && fileName[len(fileName)-1] == '"' { + fileName = fileName[1 : len(fileName)-1] + } + // Guess MIME type based on file extension + if ext := strings.ToLower(strings.TrimPrefix(fileName, ".")); ext != "" { + mimeType = GetMimeTypeByExtension(ext) + } + break + } + } + } + } + } + data := &types.LocalFileData{ + Base64Data: base64Data, + MimeType: mimeType, + Size: int64(len(fileBytes)), + } + // Store the file data in the context to avoid re-downloading + c.Set(contextKey, data) + + return data, nil +} + +func GetMimeTypeByExtension(ext string) string { + // Convert to lowercase for case-insensitive comparison + ext = strings.ToLower(ext) + switch ext { + // Text files + case "txt", "md", "markdown", "csv", "json", "xml", "html", "htm": + return "text/plain" + + // Image files + case "jpg", "jpeg": + return "image/jpeg" + case "png": + return "image/png" + case "gif": + return "image/gif" + + // Audio files + case "mp3": + return "audio/mp3" + case "wav": + return "audio/wav" + case "mpeg": + return "audio/mpeg" + + // Video files + case "mp4": + return "video/mp4" + case "wmv": + return "video/wmv" + case "flv": + return "video/flv" + case "mov": + return "video/mov" + case "mpg": + return "video/mpg" + case "avi": + return "video/avi" + case "mpegps": + return "video/mpegps" + + // Document files + case "pdf": + return "application/pdf" + + default: + return "application/octet-stream" // Default for unknown types + } +} diff --git a/service/group.go b/service/group.go new file mode 100644 index 0000000000000000000000000000000000000000..a73642c3eb1a69b6137b43b90a3b3c40ae8f22cd --- /dev/null +++ b/service/group.go @@ -0,0 +1,65 @@ +package service + +import ( + "strings" + + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" +) + +func GetUserUsableGroups(userGroup string) map[string]string { + groupsCopy := setting.GetUserUsableGroupsCopy() + if userGroup != "" { + specialSettings, b := ratio_setting.GetGroupRatioSetting().GroupSpecialUsableGroup.Get(userGroup) + if b { + // 处理特殊可用分组 + for specialGroup, desc := range specialSettings { + if strings.HasPrefix(specialGroup, "-:") { + // 移除分组 + groupToRemove := strings.TrimPrefix(specialGroup, "-:") + delete(groupsCopy, groupToRemove) + } else if strings.HasPrefix(specialGroup, "+:") { + // 添加分组 + groupToAdd := strings.TrimPrefix(specialGroup, "+:") + groupsCopy[groupToAdd] = desc + } else { + // 直接添加分组 + groupsCopy[specialGroup] = desc + } + } + } + // 如果userGroup不在UserUsableGroups中,返回UserUsableGroups + userGroup + if _, ok := groupsCopy[userGroup]; !ok { + groupsCopy[userGroup] = "用户分组" + } + } + return groupsCopy +} + +func GroupInUserUsableGroups(userGroup, groupName string) bool { + _, ok := GetUserUsableGroups(userGroup)[groupName] + return ok +} + +// GetUserAutoGroup 根据用户分组获取自动分组设置 +func GetUserAutoGroup(userGroup string) []string { + groups := GetUserUsableGroups(userGroup) + autoGroups := make([]string, 0) + for _, group := range setting.GetAutoGroups() { + if _, ok := groups[group]; ok { + autoGroups = append(autoGroups, group) + } + } + return autoGroups +} + +// GetUserGroupRatio 获取用户使用某个分组的倍率 +// userGroup 用户分组 +// group 需要获取倍率的分组 +func GetUserGroupRatio(userGroup, group string) float64 { + ratio, ok := ratio_setting.GetGroupGroupRatio(userGroup, group) + if ok { + return ratio + } + return ratio_setting.GetGroupRatio(group) +} diff --git a/service/http.go b/service/http.go new file mode 100644 index 0000000000000000000000000000000000000000..7bd54c4acd009b287306a6633504e995729fd0d5 --- /dev/null +++ b/service/http.go @@ -0,0 +1,60 @@ +package service + +import ( + "bytes" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + + "github.com/gin-gonic/gin" +) + +func CloseResponseBodyGracefully(httpResponse *http.Response) { + if httpResponse == nil || httpResponse.Body == nil { + return + } + err := httpResponse.Body.Close() + if err != nil { + common.SysError("failed to close response body: " + err.Error()) + } +} + +func IOCopyBytesGracefully(c *gin.Context, src *http.Response, data []byte) { + if c.Writer == nil { + return + } + + body := io.NopCloser(bytes.NewBuffer(data)) + + // We shouldn't set the header before we parse the response body, because the parse part may fail. + // And then we will have to send an error response, but in this case, the header has already been set. + // So the httpClient will be confused by the response. + // For example, Postman will report error, and we cannot check the response at all. + if src != nil { + for k, v := range src.Header { + // avoid setting Content-Length + if k == "Content-Length" { + continue + } + c.Writer.Header().Set(k, v[0]) + } + } + + // set Content-Length header manually BEFORE calling WriteHeader + c.Writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) + + // Write header with status code (this sends the headers) + if src != nil { + c.Writer.WriteHeader(src.StatusCode) + } else { + c.Writer.WriteHeader(http.StatusOK) + } + + _, err := io.Copy(c.Writer, body) + if err != nil { + logger.LogError(c, fmt.Sprintf("failed to copy response body: %s", err.Error())) + } +} diff --git a/service/http_client.go b/service/http_client.go new file mode 100644 index 0000000000000000000000000000000000000000..8bc0445440f39ee69552ad1c9ae08e1ff1e2e782 --- /dev/null +++ b/service/http_client.go @@ -0,0 +1,134 @@ +package service + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/system_setting" + + "golang.org/x/net/proxy" +) + +var ( + httpClient *http.Client + proxyClientLock sync.Mutex + proxyClients = make(map[string]*http.Client) +) + +func checkRedirect(req *http.Request, via []*http.Request) error { + fetchSetting := system_setting.GetFetchSetting() + urlStr := req.URL.String() + if err := common.ValidateURLWithFetchSetting(urlStr, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return fmt.Errorf("redirect to %s blocked: %v", urlStr, err) + } + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + return nil +} + +func InitHttpClient() { + if common.RelayTimeout == 0 { + httpClient = &http.Client{ + CheckRedirect: checkRedirect, + } + } else { + httpClient = &http.Client{ + Timeout: time.Duration(common.RelayTimeout) * time.Second, + CheckRedirect: checkRedirect, + } + } +} + +func GetHttpClient() *http.Client { + return httpClient +} + +// ResetProxyClientCache 清空代理客户端缓存,确保下次使用时重新初始化 +func ResetProxyClientCache() { + proxyClientLock.Lock() + defer proxyClientLock.Unlock() + for _, client := range proxyClients { + if transport, ok := client.Transport.(*http.Transport); ok && transport != nil { + transport.CloseIdleConnections() + } + } + proxyClients = make(map[string]*http.Client) +} + +// NewProxyHttpClient 创建支持代理的 HTTP 客户端 +func NewProxyHttpClient(proxyURL string) (*http.Client, error) { + if proxyURL == "" { + return http.DefaultClient, nil + } + + proxyClientLock.Lock() + if client, ok := proxyClients[proxyURL]; ok { + proxyClientLock.Unlock() + return client, nil + } + proxyClientLock.Unlock() + + parsedURL, err := url.Parse(proxyURL) + if err != nil { + return nil, err + } + + switch parsedURL.Scheme { + case "http", "https": + client := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(parsedURL), + }, + CheckRedirect: checkRedirect, + } + client.Timeout = time.Duration(common.RelayTimeout) * time.Second + proxyClientLock.Lock() + proxyClients[proxyURL] = client + proxyClientLock.Unlock() + return client, nil + + case "socks5", "socks5h": + // 获取认证信息 + var auth *proxy.Auth + if parsedURL.User != nil { + auth = &proxy.Auth{ + User: parsedURL.User.Username(), + Password: "", + } + if password, ok := parsedURL.User.Password(); ok { + auth.Password = password + } + } + + // 创建 SOCKS5 代理拨号器 + // proxy.SOCKS5 使用 tcp 参数,所有 TCP 连接包括 DNS 查询都将通过代理进行。行为与 socks5h 相同 + dialer, err := proxy.SOCKS5("tcp", parsedURL.Host, auth, proxy.Direct) + if err != nil { + return nil, err + } + + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) + }, + }, + CheckRedirect: checkRedirect, + } + client.Timeout = time.Duration(common.RelayTimeout) * time.Second + proxyClientLock.Lock() + proxyClients[proxyURL] = client + proxyClientLock.Unlock() + return client, nil + + default: + return nil, fmt.Errorf("unsupported proxy scheme: %s, must be http, https, socks5 or socks5h", parsedURL.Scheme) + } +} diff --git a/service/image.go b/service/image.go new file mode 100644 index 0000000000000000000000000000000000000000..fa5c175bca71a4c8fdf7dc428e1764eb815fc2a4 --- /dev/null +++ b/service/image.go @@ -0,0 +1,178 @@ +package service + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "image" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + + "golang.org/x/image/webp" +) + +// return image.Config, format, clean base64 string, error +func DecodeBase64ImageData(base64String string) (image.Config, string, string, error) { + // 去除base64数据的URL前缀(如果有) + if idx := strings.Index(base64String, ","); idx != -1 { + base64String = base64String[idx+1:] + } + + if len(base64String) == 0 { + return image.Config{}, "", "", errors.New("base64 string is empty") + } + + // 将base64字符串解码为字节切片 + decodedData, err := base64.StdEncoding.DecodeString(base64String) + if err != nil { + fmt.Println("Error: Failed to decode base64 string") + return image.Config{}, "", "", fmt.Errorf("failed to decode base64 string: %s", err.Error()) + } + + // 创建一个bytes.Buffer用于存储解码后的数据 + reader := bytes.NewReader(decodedData) + config, format, err := getImageConfig(reader) + return config, format, base64String, err +} + +func DecodeBase64FileData(base64String string) (string, string, error) { + var mimeType string + var idx int + idx = strings.Index(base64String, ",") + if idx == -1 { + _, file_type, base64, err := DecodeBase64ImageData(base64String) + return "image/" + file_type, base64, err + } + mimeType = base64String[:idx] + base64String = base64String[idx+1:] + idx = strings.Index(mimeType, ";") + if idx == -1 { + _, file_type, base64, err := DecodeBase64ImageData(base64String) + return "image/" + file_type, base64, err + } + mimeType = mimeType[:idx] + idx = strings.Index(mimeType, ":") + if idx == -1 { + _, file_type, base64, err := DecodeBase64ImageData(base64String) + return "image/" + file_type, base64, err + } + mimeType = mimeType[idx+1:] + return mimeType, base64String, nil +} + +// GetImageFromUrl 获取图片的类型和base64编码的数据 +func GetImageFromUrl(url string) (mimeType string, data string, err error) { + resp, err := DoDownloadRequest(url) + if err != nil { + return "", "", fmt.Errorf("failed to download image: %w", err) + } + defer resp.Body.Close() + + // Check HTTP status code + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("failed to download image: HTTP %d", resp.StatusCode) + } + + contentType := resp.Header.Get("Content-Type") + if contentType != "application/octet-stream" && !strings.HasPrefix(contentType, "image/") { + return "", "", fmt.Errorf("invalid content type: %s, required image/*", contentType) + } + maxImageSize := int64(constant.MaxFileDownloadMB * 1024 * 1024) + + // Check Content-Length if available + if resp.ContentLength > maxImageSize { + return "", "", fmt.Errorf("image size %d exceeds maximum allowed size of %d bytes", resp.ContentLength, maxImageSize) + } + + // Use LimitReader to prevent reading oversized images + limitReader := io.LimitReader(resp.Body, maxImageSize) + buffer := &bytes.Buffer{} + + written, err := io.Copy(buffer, limitReader) + if err != nil { + return "", "", fmt.Errorf("failed to read image data: %w", err) + } + if written >= maxImageSize { + return "", "", fmt.Errorf("image size exceeds maximum allowed size of %d bytes", maxImageSize) + } + + data = base64.StdEncoding.EncodeToString(buffer.Bytes()) + mimeType = contentType + + // Handle application/octet-stream type + if mimeType == "application/octet-stream" { + _, format, _, err := DecodeBase64ImageData(data) + if err != nil { + return "", "", err + } + mimeType = "image/" + format + } + + return mimeType, data, nil +} + +func DecodeUrlImageData(imageUrl string) (image.Config, string, error) { + response, err := DoDownloadRequest(imageUrl) + if err != nil { + common.SysLog(fmt.Sprintf("fail to get image from url: %s", err.Error())) + return image.Config{}, "", err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + err = errors.New(fmt.Sprintf("fail to get image from url: %s", response.Status)) + return image.Config{}, "", err + } + + mimeType := response.Header.Get("Content-Type") + + if mimeType != "application/octet-stream" && !strings.HasPrefix(mimeType, "image/") { + return image.Config{}, "", fmt.Errorf("invalid content type: %s, required image/*", mimeType) + } + + var readData []byte + for _, limit := range []int64{1024 * 8, 1024 * 24, 1024 * 64} { + common.SysLog(fmt.Sprintf("try to decode image config with limit: %d", limit)) + + // 从response.Body读取更多的数据直到达到当前的限制 + additionalData := make([]byte, limit-int64(len(readData))) + n, _ := io.ReadFull(response.Body, additionalData) + readData = append(readData, additionalData[:n]...) + + // 使用io.MultiReader组合已经读取的数据和response.Body + limitReader := io.MultiReader(bytes.NewReader(readData), response.Body) + + var config image.Config + var format string + config, format, err = getImageConfig(limitReader) + if err == nil { + return config, format, nil + } + } + + return image.Config{}, "", err // 返回最后一个错误 +} + +func getImageConfig(reader io.Reader) (image.Config, string, error) { + // 读取图片的头部信息来获取图片尺寸 + config, format, err := image.DecodeConfig(reader) + if err != nil { + err = errors.New(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error())) + common.SysLog(err.Error()) + config, err = webp.DecodeConfig(reader) + if err != nil { + err = errors.New(fmt.Sprintf("fail to decode image config(webp): %s", err.Error())) + common.SysLog(err.Error()) + } + format = "webp" + } + if err != nil { + return image.Config{}, "", err + } + return config, format, nil +} diff --git a/service/log_info_generate.go b/service/log_info_generate.go new file mode 100644 index 0000000000000000000000000000000000000000..1bd7df673affb5c055ddd47086c92c52da6b277f --- /dev/null +++ b/service/log_info_generate.go @@ -0,0 +1,130 @@ +package service + +import ( + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func appendRequestPath(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other map[string]interface{}) { + if other == nil { + return + } + if ctx != nil && ctx.Request != nil && ctx.Request.URL != nil { + if path := ctx.Request.URL.Path; path != "" { + other["request_path"] = path + return + } + } + if relayInfo != nil && relayInfo.RequestURLPath != "" { + path := relayInfo.RequestURLPath + if idx := strings.Index(path, "?"); idx != -1 { + path = path[:idx] + } + other["request_path"] = path + } +} + +func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64, + cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} { + other := make(map[string]interface{}) + other["model_ratio"] = modelRatio + other["group_ratio"] = groupRatio + other["completion_ratio"] = completionRatio + other["cache_tokens"] = cacheTokens + other["cache_ratio"] = cacheRatio + other["model_price"] = modelPrice + other["user_group_ratio"] = userGroupRatio + other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli()) + if relayInfo.ReasoningEffort != "" { + other["reasoning_effort"] = relayInfo.ReasoningEffort + } + if relayInfo.IsModelMapped { + other["is_model_mapped"] = true + other["upstream_model_name"] = relayInfo.UpstreamModelName + } + + isSystemPromptOverwritten := common.GetContextKeyBool(ctx, constant.ContextKeySystemPromptOverride) + if isSystemPromptOverwritten { + other["is_system_prompt_overwritten"] = true + } + + adminInfo := make(map[string]interface{}) + adminInfo["use_channel"] = ctx.GetStringSlice("use_channel") + isMultiKey := common.GetContextKeyBool(ctx, constant.ContextKeyChannelIsMultiKey) + if isMultiKey { + adminInfo["is_multi_key"] = true + adminInfo["multi_key_index"] = common.GetContextKeyInt(ctx, constant.ContextKeyChannelMultiKeyIndex) + } + + isLocalCountTokens := common.GetContextKeyBool(ctx, constant.ContextKeyLocalCountTokens) + if isLocalCountTokens { + adminInfo["local_count_tokens"] = isLocalCountTokens + } + + other["admin_info"] = adminInfo + appendRequestPath(ctx, relayInfo, other) + return other +} + +func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} { + info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio) + info["ws"] = true + info["audio_input"] = usage.InputTokenDetails.AudioTokens + info["audio_output"] = usage.OutputTokenDetails.AudioTokens + info["text_input"] = usage.InputTokenDetails.TextTokens + info["text_output"] = usage.OutputTokenDetails.TextTokens + info["audio_ratio"] = audioRatio + info["audio_completion_ratio"] = audioCompletionRatio + return info +} + +func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} { + info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio) + info["audio"] = true + info["audio_input"] = usage.PromptTokensDetails.AudioTokens + info["audio_output"] = usage.CompletionTokenDetails.AudioTokens + info["text_input"] = usage.PromptTokensDetails.TextTokens + info["text_output"] = usage.CompletionTokenDetails.TextTokens + info["audio_ratio"] = audioRatio + info["audio_completion_ratio"] = audioCompletionRatio + return info +} + +func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64, + cacheTokens int, cacheRatio float64, + cacheCreationTokens int, cacheCreationRatio float64, + cacheCreationTokens5m int, cacheCreationRatio5m float64, + cacheCreationTokens1h int, cacheCreationRatio1h float64, + modelPrice float64, userGroupRatio float64) map[string]interface{} { + info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio) + info["claude"] = true + info["cache_creation_tokens"] = cacheCreationTokens + info["cache_creation_ratio"] = cacheCreationRatio + if cacheCreationTokens5m != 0 { + info["cache_creation_tokens_5m"] = cacheCreationTokens5m + info["cache_creation_ratio_5m"] = cacheCreationRatio5m + } + if cacheCreationTokens1h != 0 { + info["cache_creation_tokens_1h"] = cacheCreationTokens1h + info["cache_creation_ratio_1h"] = cacheCreationRatio1h + } + return info +} + +func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.PerCallPriceData) map[string]interface{} { + other := make(map[string]interface{}) + other["model_price"] = priceData.ModelPrice + other["group_ratio"] = priceData.GroupRatioInfo.GroupRatio + if priceData.GroupRatioInfo.HasSpecialRatio { + other["user_group_ratio"] = priceData.GroupRatioInfo.GroupSpecialRatio + } + appendRequestPath(nil, relayInfo, other) + return other +} diff --git a/service/midjourney.go b/service/midjourney.go new file mode 100644 index 0000000000000000000000000000000000000000..9b2eb5ca75ec550d2fa261c30264ecaadeaf8394 --- /dev/null +++ b/service/midjourney.go @@ -0,0 +1,259 @@ +package service + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/setting" + + "github.com/gin-gonic/gin" +) + +func CoverActionToModelName(mjAction string) string { + modelName := "mj_" + strings.ToLower(mjAction) + if mjAction == constant.MjActionSwapFace { + modelName = "swap_face" + } + return modelName +} + +func GetMjRequestModel(relayMode int, midjRequest *dto.MidjourneyRequest) (string, *dto.MidjourneyResponse, bool) { + action := "" + if relayMode == relayconstant.RelayModeMidjourneyAction { + // plus request + err := CoverPlusActionToNormalAction(midjRequest) + if err != nil { + return "", err, false + } + action = midjRequest.Action + } else { + switch relayMode { + case relayconstant.RelayModeMidjourneyImagine: + action = constant.MjActionImagine + case relayconstant.RelayModeMidjourneyVideo: + action = constant.MjActionVideo + case relayconstant.RelayModeMidjourneyEdits: + action = constant.MjActionEdits + case relayconstant.RelayModeMidjourneyDescribe: + action = constant.MjActionDescribe + case relayconstant.RelayModeMidjourneyBlend: + action = constant.MjActionBlend + case relayconstant.RelayModeMidjourneyShorten: + action = constant.MjActionShorten + case relayconstant.RelayModeMidjourneyChange: + action = midjRequest.Action + case relayconstant.RelayModeMidjourneyModal: + action = constant.MjActionModal + case relayconstant.RelayModeSwapFace: + action = constant.MjActionSwapFace + case relayconstant.RelayModeMidjourneyUpload: + action = constant.MjActionUpload + case relayconstant.RelayModeMidjourneySimpleChange: + params := ConvertSimpleChangeParams(midjRequest.Content) + if params == nil { + return "", MidjourneyErrorWrapper(constant.MjRequestError, "invalid_request"), false + } + action = params.Action + case relayconstant.RelayModeMidjourneyTaskFetch, relayconstant.RelayModeMidjourneyTaskFetchByCondition, relayconstant.RelayModeMidjourneyNotify: + return "", nil, true + default: + return "", MidjourneyErrorWrapper(constant.MjRequestError, "unknown_relay_action"), false + } + } + modelName := CoverActionToModelName(action) + return modelName, nil, true +} + +func CoverPlusActionToNormalAction(midjRequest *dto.MidjourneyRequest) *dto.MidjourneyResponse { + // "customId": "MJ::JOB::upsample::2::3dbbd469-36af-4a0f-8f02-df6c579e7011" + customId := midjRequest.CustomId + if customId == "" { + return MidjourneyErrorWrapper(constant.MjRequestError, "custom_id_is_required") + } + splits := strings.Split(customId, "::") + var action string + if splits[1] == "JOB" { + action = splits[2] + } else { + action = splits[1] + } + + if action == "" { + return MidjourneyErrorWrapper(constant.MjRequestError, "unknown_action") + } + if strings.Contains(action, "upsample") { + index, err := strconv.Atoi(splits[3]) + if err != nil { + return MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") + } + midjRequest.Index = index + midjRequest.Action = constant.MjActionUpscale + } else if strings.Contains(action, "variation") { + midjRequest.Index = 1 + if action == "variation" { + index, err := strconv.Atoi(splits[3]) + if err != nil { + return MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") + } + midjRequest.Index = index + midjRequest.Action = constant.MjActionVariation + } else if action == "low_variation" { + midjRequest.Action = constant.MjActionLowVariation + } else if action == "high_variation" { + midjRequest.Action = constant.MjActionHighVariation + } + } else if strings.Contains(action, "pan") { + midjRequest.Action = constant.MjActionPan + midjRequest.Index = 1 + } else if strings.Contains(action, "reroll") { + midjRequest.Action = constant.MjActionReRoll + midjRequest.Index = 1 + } else if action == "Outpaint" { + midjRequest.Action = constant.MjActionZoom + midjRequest.Index = 1 + } else if action == "CustomZoom" { + midjRequest.Action = constant.MjActionCustomZoom + midjRequest.Index = 1 + } else if action == "Inpaint" { + midjRequest.Action = constant.MjActionInPaint + midjRequest.Index = 1 + } else { + return MidjourneyErrorWrapper(constant.MjRequestError, "unknown_action:"+customId) + } + return nil +} + +func ConvertSimpleChangeParams(content string) *dto.MidjourneyRequest { + split := strings.Split(content, " ") + if len(split) != 2 { + return nil + } + + action := strings.ToLower(split[1]) + changeParams := &dto.MidjourneyRequest{} + changeParams.TaskId = split[0] + + if action[0] == 'u' { + changeParams.Action = "UPSCALE" + } else if action[0] == 'v' { + changeParams.Action = "VARIATION" + } else if action == "r" { + changeParams.Action = "REROLL" + return changeParams + } else { + return nil + } + + index, err := strconv.Atoi(action[1:2]) + if err != nil || index < 1 || index > 4 { + return nil + } + changeParams.Index = index + return changeParams +} + +func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestURL string) (*dto.MidjourneyResponseWithStatusCode, []byte, error) { + var nullBytes []byte + //var requestBody io.Reader + //requestBody = c.Request.Body + // read request body to json, delete accountFilter and notifyHook + var mapResult map[string]interface{} + // if get request, no need to read request body + if c.Request.Method != "GET" { + err := json.NewDecoder(c.Request.Body).Decode(&mapResult) + if err != nil { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "read_request_body_failed", http.StatusInternalServerError), nullBytes, err + } + if !setting.MjAccountFilterEnabled { + delete(mapResult, "accountFilter") + } + if !setting.MjNotifyEnabled { + delete(mapResult, "notifyHook") + } + //req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + // make new request with mapResult + } + if setting.MjModeClearEnabled { + if prompt, ok := mapResult["prompt"].(string); ok { + prompt = strings.Replace(prompt, "--fast", "", -1) + prompt = strings.Replace(prompt, "--relax", "", -1) + prompt = strings.Replace(prompt, "--turbo", "", -1) + + mapResult["prompt"] = prompt + } + } + reqBody, err := json.Marshal(mapResult) + if err != nil { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "marshal_request_body_failed", http.StatusInternalServerError), nullBytes, err + } + req, err := http.NewRequest(c.Request.Method, fullRequestURL, strings.NewReader(string(reqBody))) + if err != nil { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "create_request_failed", http.StatusInternalServerError), nullBytes, err + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + // 使用带有超时的 context 创建新的请求 + req = req.WithContext(ctx) + req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) + req.Header.Set("Accept", c.Request.Header.Get("Accept")) + auth := common.GetContextKeyString(c, constant.ContextKeyChannelKey) + if auth != "" { + auth = strings.TrimPrefix(auth, "Bearer ") + req.Header.Set("mj-api-secret", auth) + } + defer cancel() + resp, err := GetHttpClient().Do(req) + if err != nil { + common.SysLog("do request failed: " + err.Error()) + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "do_request_failed", http.StatusInternalServerError), nullBytes, err + } + statusCode := resp.StatusCode + //if statusCode != 200 { + // return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "bad_response_status_code", statusCode), nullBytes, nil + //} + err = req.Body.Close() + if err != nil { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "close_request_body_failed", statusCode), nullBytes, err + } + err = c.Request.Body.Close() + if err != nil { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "close_request_body_failed", statusCode), nullBytes, err + } + var midjResponse dto.MidjourneyResponse + var midjourneyUploadsResponse dto.MidjourneyUploadResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "read_response_body_failed", statusCode), nullBytes, err + } + CloseResponseBodyGracefully(resp) + respStr := string(responseBody) + log.Printf("respStr: %s", respStr) + if respStr == "" { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "empty_response_body", statusCode), responseBody, nil + } else { + err = json.Unmarshal(responseBody, &midjResponse) + if err != nil { + err2 := json.Unmarshal(responseBody, &midjourneyUploadsResponse) + if err2 != nil { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "unmarshal_response_body_failed", statusCode), responseBody, err + } + } + } + //log.Printf("midjResponse: %v", midjResponse) + //for k, v := range resp.Header { + // c.Writer.Header().Set(k, v[0]) + //} + return &dto.MidjourneyResponseWithStatusCode{ + StatusCode: statusCode, + Response: midjResponse, + }, responseBody, nil +} diff --git a/service/notify-limit.go b/service/notify-limit.go new file mode 100644 index 0000000000000000000000000000000000000000..cad5d7bc182486ea165e77dcd736ea7956bc1b85 --- /dev/null +++ b/service/notify-limit.go @@ -0,0 +1,118 @@ +package service + +import ( + "fmt" + "strconv" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/bytedance/gopkg/util/gopool" +) + +// notifyLimitStore is used for in-memory rate limiting when Redis is disabled +var ( + notifyLimitStore sync.Map + cleanupOnce sync.Once +) + +type limitCount struct { + Count int + Timestamp time.Time +} + +func getDuration() time.Duration { + minute := constant.NotificationLimitDurationMinute + return time.Duration(minute) * time.Minute +} + +// startCleanupTask starts a background task to clean up expired entries +func startCleanupTask() { + gopool.Go(func() { + for { + time.Sleep(time.Hour) + now := time.Now() + notifyLimitStore.Range(func(key, value interface{}) bool { + if limit, ok := value.(limitCount); ok { + if now.Sub(limit.Timestamp) >= getDuration() { + notifyLimitStore.Delete(key) + } + } + return true + }) + } + }) +} + +// CheckNotificationLimit checks if the user has exceeded their notification limit +// Returns true if the user can send notification, false if limit exceeded +func CheckNotificationLimit(userId int, notifyType string) (bool, error) { + if common.RedisEnabled { + return checkRedisLimit(userId, notifyType) + } + return checkMemoryLimit(userId, notifyType) +} + +func checkRedisLimit(userId int, notifyType string) (bool, error) { + key := fmt.Sprintf("notify_limit:%d:%s:%s", userId, notifyType, time.Now().Format("2006010215")) + + // Get current count + count, err := common.RedisGet(key) + if err != nil && err.Error() != "redis: nil" { + return false, fmt.Errorf("failed to get notification count: %w", err) + } + + // If key doesn't exist, initialize it + if count == "" { + err = common.RedisSet(key, "1", getDuration()) + return true, err + } + + currentCount, _ := strconv.Atoi(count) + limit := constant.NotifyLimitCount + + // Check if limit is already reached + if currentCount >= limit { + return false, nil + } + + // Only increment if under limit + err = common.RedisIncr(key, 1) + if err != nil { + return false, fmt.Errorf("failed to increment notification count: %w", err) + } + + return true, nil +} + +func checkMemoryLimit(userId int, notifyType string) (bool, error) { + // Ensure cleanup task is started + cleanupOnce.Do(startCleanupTask) + + key := fmt.Sprintf("%d:%s:%s", userId, notifyType, time.Now().Format("2006010215")) + now := time.Now() + + // Get current limit count or initialize new one + var currentLimit limitCount + if value, ok := notifyLimitStore.Load(key); ok { + currentLimit = value.(limitCount) + // Check if the entry has expired + if now.Sub(currentLimit.Timestamp) >= getDuration() { + currentLimit = limitCount{Count: 0, Timestamp: now} + } + } else { + currentLimit = limitCount{Count: 0, Timestamp: now} + } + + // Increment count + currentLimit.Count++ + + // Check against limits + limit := constant.NotifyLimitCount + + // Store updated count + notifyLimitStore.Store(key, currentLimit) + + return currentLimit.Count <= limit, nil +} diff --git a/service/passkey/service.go b/service/passkey/service.go new file mode 100644 index 0000000000000000000000000000000000000000..4d29d1aefa621c5780769d7fa005d0c3c0e20990 --- /dev/null +++ b/service/passkey/service.go @@ -0,0 +1,177 @@ +package passkey + +import ( + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/go-webauthn/webauthn/protocol" + webauthn "github.com/go-webauthn/webauthn/webauthn" +) + +const ( + RegistrationSessionKey = "passkey_registration_session" + LoginSessionKey = "passkey_login_session" + VerifySessionKey = "passkey_verify_session" +) + +// BuildWebAuthn constructs a WebAuthn instance using the current passkey settings and request context. +func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) { + settings := system_setting.GetPasskeySettings() + if settings == nil { + return nil, errors.New("未找到 Passkey 设置") + } + + displayName := strings.TrimSpace(settings.RPDisplayName) + if displayName == "" { + displayName = common.SystemName + } + + origins, err := resolveOrigins(r, settings) + if err != nil { + return nil, err + } + + rpID, err := resolveRPID(r, settings, origins) + if err != nil { + return nil, err + } + + selection := protocol.AuthenticatorSelection{ + ResidentKey: protocol.ResidentKeyRequirementRequired, + RequireResidentKey: protocol.ResidentKeyRequired(), + UserVerification: protocol.UserVerificationRequirement(settings.UserVerification), + } + if selection.UserVerification == "" { + selection.UserVerification = protocol.VerificationPreferred + } + if attachment := strings.TrimSpace(settings.AttachmentPreference); attachment != "" { + selection.AuthenticatorAttachment = protocol.AuthenticatorAttachment(attachment) + } + + config := &webauthn.Config{ + RPID: rpID, + RPDisplayName: displayName, + RPOrigins: origins, + AuthenticatorSelection: selection, + Debug: common.DebugEnabled, + Timeouts: webauthn.TimeoutsConfig{ + Login: webauthn.TimeoutConfig{ + Enforce: true, + Timeout: 2 * time.Minute, + TimeoutUVD: 2 * time.Minute, + }, + Registration: webauthn.TimeoutConfig{ + Enforce: true, + Timeout: 2 * time.Minute, + TimeoutUVD: 2 * time.Minute, + }, + }, + } + + return webauthn.New(config) +} + +func resolveOrigins(r *http.Request, settings *system_setting.PasskeySettings) ([]string, error) { + originsStr := strings.TrimSpace(settings.Origins) + if originsStr != "" { + originList := strings.Split(originsStr, ",") + origins := make([]string, 0, len(originList)) + for _, origin := range originList { + trimmed := strings.TrimSpace(origin) + if trimmed == "" { + continue + } + if !settings.AllowInsecureOrigin && strings.HasPrefix(strings.ToLower(trimmed), "http://") { + return nil, fmt.Errorf("Passkey 不允许使用不安全的 Origin: %s", trimmed) + } + origins = append(origins, trimmed) + } + if len(origins) == 0 { + // 如果配置了Origins但过滤后为空,使用自动推导 + goto autoDetect + } + return origins, nil + } + +autoDetect: + scheme := detectScheme(r) + if scheme == "http" && !settings.AllowInsecureOrigin && r.Host != "localhost" && r.Host != "127.0.0.1" && !strings.HasPrefix(r.Host, "127.0.0.1:") && !strings.HasPrefix(r.Host, "localhost:") { + return nil, fmt.Errorf("Passkey 仅支持 HTTPS,当前访问: %s://%s,请在 Passkey 设置中允许不安全 Origin 或配置 HTTPS", scheme, r.Host) + } + // 优先使用请求的完整Host(包含端口) + host := r.Host + + // 如果无法从请求获取Host,尝试从ServerAddress获取 + if host == "" && system_setting.ServerAddress != "" { + if parsed, err := url.Parse(system_setting.ServerAddress); err == nil && parsed.Host != "" { + host = parsed.Host + if scheme == "" && parsed.Scheme != "" { + scheme = parsed.Scheme + } + } + } + if host == "" { + return nil, fmt.Errorf("无法确定 Passkey 的 Origin,请在系统设置或 Passkey 设置中指定。当前 Host: '%s', ServerAddress: '%s'", r.Host, system_setting.ServerAddress) + } + if scheme == "" { + scheme = "https" + } + origin := fmt.Sprintf("%s://%s", scheme, host) + return []string{origin}, nil +} + +func resolveRPID(r *http.Request, settings *system_setting.PasskeySettings, origins []string) (string, error) { + rpID := strings.TrimSpace(settings.RPID) + if rpID != "" { + return hostWithoutPort(rpID), nil + } + if len(origins) == 0 { + return "", errors.New("Passkey 未配置 Origin,无法推导 RPID") + } + parsed, err := url.Parse(origins[0]) + if err != nil { + return "", fmt.Errorf("无法解析 Passkey Origin: %w", err) + } + return hostWithoutPort(parsed.Host), nil +} + +func hostWithoutPort(host string) string { + host = strings.TrimSpace(host) + if host == "" { + return "" + } + if strings.Contains(host, ":") { + if host, _, err := net.SplitHostPort(host); err == nil { + return host + } + } + return host +} + +func detectScheme(r *http.Request) string { + if r == nil { + return "" + } + if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { + parts := strings.Split(proto, ",") + return strings.ToLower(strings.TrimSpace(parts[0])) + } + if r.TLS != nil { + return "https" + } + if r.URL != nil && r.URL.Scheme != "" { + return strings.ToLower(r.URL.Scheme) + } + if r.Header.Get("X-Forwarded-Protocol") != "" { + return strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Protocol"))) + } + return "http" +} diff --git a/service/passkey/session.go b/service/passkey/session.go new file mode 100644 index 0000000000000000000000000000000000000000..15e61932690f9cbba06490f97b31c571ce5740b1 --- /dev/null +++ b/service/passkey/session.go @@ -0,0 +1,50 @@ +package passkey + +import ( + "encoding/json" + "errors" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + webauthn "github.com/go-webauthn/webauthn/webauthn" +) + +var errSessionNotFound = errors.New("Passkey 会话不存在或已过期") + +func SaveSessionData(c *gin.Context, key string, data *webauthn.SessionData) error { + session := sessions.Default(c) + if data == nil { + session.Delete(key) + return session.Save() + } + payload, err := json.Marshal(data) + if err != nil { + return err + } + session.Set(key, string(payload)) + return session.Save() +} + +func PopSessionData(c *gin.Context, key string) (*webauthn.SessionData, error) { + session := sessions.Default(c) + raw := session.Get(key) + if raw == nil { + return nil, errSessionNotFound + } + session.Delete(key) + _ = session.Save() + var data webauthn.SessionData + switch value := raw.(type) { + case string: + if err := json.Unmarshal([]byte(value), &data); err != nil { + return nil, err + } + case []byte: + if err := json.Unmarshal(value, &data); err != nil { + return nil, err + } + default: + return nil, errors.New("Passkey 会话格式无效") + } + return &data, nil +} diff --git a/service/passkey/user.go b/service/passkey/user.go new file mode 100644 index 0000000000000000000000000000000000000000..2ec248a9dbe34fb708e00d150318a5130ded3a16 --- /dev/null +++ b/service/passkey/user.go @@ -0,0 +1,71 @@ +package passkey + +import ( + "fmt" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/model" + + webauthn "github.com/go-webauthn/webauthn/webauthn" +) + +type WebAuthnUser struct { + user *model.User + credential *model.PasskeyCredential +} + +func NewWebAuthnUser(user *model.User, credential *model.PasskeyCredential) *WebAuthnUser { + return &WebAuthnUser{user: user, credential: credential} +} + +func (u *WebAuthnUser) WebAuthnID() []byte { + if u == nil || u.user == nil { + return nil + } + return []byte(strconv.Itoa(u.user.Id)) +} + +func (u *WebAuthnUser) WebAuthnName() string { + if u == nil || u.user == nil { + return "" + } + name := strings.TrimSpace(u.user.Username) + if name == "" { + return fmt.Sprintf("user-%d", u.user.Id) + } + return name +} + +func (u *WebAuthnUser) WebAuthnDisplayName() string { + if u == nil || u.user == nil { + return "" + } + display := strings.TrimSpace(u.user.DisplayName) + if display != "" { + return display + } + return u.WebAuthnName() +} + +func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential { + if u == nil || u.credential == nil { + return nil + } + cred := u.credential.ToWebAuthnCredential() + return []webauthn.Credential{cred} +} + +func (u *WebAuthnUser) ModelUser() *model.User { + if u == nil { + return nil + } + return u.user +} + +func (u *WebAuthnUser) PasskeyCredential() *model.PasskeyCredential { + if u == nil { + return nil + } + return u.credential +} diff --git a/service/pre_consume_quota.go b/service/pre_consume_quota.go new file mode 100644 index 0000000000000000000000000000000000000000..995a3f97151f7e864bd93295c7a6a2594776ba82 --- /dev/null +++ b/service/pre_consume_quota.go @@ -0,0 +1,79 @@ +package service + +import ( + "fmt" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-gonic/gin" +) + +func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) { + if relayInfo.FinalPreConsumedQuota != 0 { + logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(relayInfo.FinalPreConsumedQuota))) + gopool.Go(func() { + relayInfoCopy := *relayInfo + + err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false) + if err != nil { + common.SysLog("error return pre-consumed quota: " + err.Error()) + } + }) + } +} + +// PreConsumeQuota checks if the user has enough quota to pre-consume. +// It returns the pre-consumed quota if successful, or an error if not. +func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError { + userQuota, err := model.GetUserQuota(relayInfo.UserId, false) + if err != nil { + return types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) + } + if userQuota <= 0 { + return types.NewErrorWithStatusCode(fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) + } + if userQuota-preConsumedQuota < 0 { + return types.NewErrorWithStatusCode(fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) + } + + trustQuota := common.GetTrustQuota() + + relayInfo.UserQuota = userQuota + if userQuota > trustQuota { + // 用户额度充足,判断令牌额度是否充足 + if !relayInfo.TokenUnlimited { + // 非无限令牌,判断令牌额度是否充足 + tokenQuota := c.GetInt("token_quota") + if tokenQuota > trustQuota { + // 令牌额度充足,信任令牌 + preConsumedQuota = 0 + logger.LogInfo(c, fmt.Sprintf("用户 %d 剩余额度 %s 且令牌 %d 额度 %d 充足, 信任且不需要预扣费", relayInfo.UserId, logger.FormatQuota(userQuota), relayInfo.TokenId, tokenQuota)) + } + } else { + // in this case, we do not pre-consume quota + // because the user has enough quota + preConsumedQuota = 0 + logger.LogInfo(c, fmt.Sprintf("用户 %d 额度充足且为无限额度令牌, 信任且不需要预扣费", relayInfo.UserId)) + } + } + + if preConsumedQuota > 0 { + err := PreConsumeTokenQuota(relayInfo, preConsumedQuota) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) + } + err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota) + if err != nil { + return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry()) + } + logger.LogInfo(c, fmt.Sprintf("用户 %d 预扣费 %s, 预扣费后剩余额度: %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota), logger.FormatQuota(userQuota-preConsumedQuota))) + } + relayInfo.FinalPreConsumedQuota = preConsumedQuota + return nil +} diff --git a/service/quota.go b/service/quota.go new file mode 100644 index 0000000000000000000000000000000000000000..0f41b851bf432f5d457a7edb8da7294b40215909 --- /dev/null +++ b/service/quota.go @@ -0,0 +1,581 @@ +package service + +import ( + "errors" + "fmt" + "log" + "math" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/bytedance/gopkg/util/gopool" + + "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" +) + +type TokenDetails struct { + TextTokens int + AudioTokens int +} + +type QuotaInfo struct { + InputDetails TokenDetails + OutputDetails TokenDetails + ModelName string + UsePrice bool + ModelPrice float64 + ModelRatio float64 + GroupRatio float64 +} + +func hasCustomModelRatio(modelName string, currentRatio float64) bool { + defaultRatio, exists := ratio_setting.GetDefaultModelRatioMap()[modelName] + if !exists { + return true + } + return currentRatio != defaultRatio +} + +func calculateAudioQuota(info QuotaInfo) int { + if info.UsePrice { + modelPrice := decimal.NewFromFloat(info.ModelPrice) + quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + groupRatio := decimal.NewFromFloat(info.GroupRatio) + + quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio) + return int(quota.IntPart()) + } + + completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(info.ModelName)) + audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(info.ModelName)) + audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(info.ModelName)) + + groupRatio := decimal.NewFromFloat(info.GroupRatio) + modelRatio := decimal.NewFromFloat(info.ModelRatio) + ratio := groupRatio.Mul(modelRatio) + + inputTextTokens := decimal.NewFromInt(int64(info.InputDetails.TextTokens)) + outputTextTokens := decimal.NewFromInt(int64(info.OutputDetails.TextTokens)) + inputAudioTokens := decimal.NewFromInt(int64(info.InputDetails.AudioTokens)) + outputAudioTokens := decimal.NewFromInt(int64(info.OutputDetails.AudioTokens)) + + quota := decimal.Zero + quota = quota.Add(inputTextTokens) + quota = quota.Add(outputTextTokens.Mul(completionRatio)) + quota = quota.Add(inputAudioTokens.Mul(audioRatio)) + quota = quota.Add(outputAudioTokens.Mul(audioRatio).Mul(audioCompletionRatio)) + + quota = quota.Mul(ratio) + + // If ratio is not zero and quota is less than or equal to zero, set quota to 1 + if !ratio.IsZero() && quota.LessThanOrEqual(decimal.Zero) { + quota = decimal.NewFromInt(1) + } + + return int(quota.Round(0).IntPart()) +} + +func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage) error { + if relayInfo.UsePrice { + return nil + } + userQuota, err := model.GetUserQuota(relayInfo.UserId, false) + if err != nil { + return err + } + + token, err := model.GetTokenByKey(strings.TrimLeft(relayInfo.TokenKey, "sk-"), false) + if err != nil { + return err + } + + modelName := relayInfo.OriginModelName + textInputTokens := usage.InputTokenDetails.TextTokens + textOutTokens := usage.OutputTokenDetails.TextTokens + audioInputTokens := usage.InputTokenDetails.AudioTokens + audioOutTokens := usage.OutputTokenDetails.AudioTokens + groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup) + modelRatio, _, _ := ratio_setting.GetModelRatio(modelName) + + autoGroup, exists := ctx.Get("auto_group") + if exists { + groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string)) + log.Printf("final group ratio: %f", groupRatio) + relayInfo.UsingGroup = autoGroup.(string) + } + + actualGroupRatio := groupRatio + userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup) + if ok { + actualGroupRatio = userGroupRatio + } + + quotaInfo := QuotaInfo{ + InputDetails: TokenDetails{ + TextTokens: textInputTokens, + AudioTokens: audioInputTokens, + }, + OutputDetails: TokenDetails{ + TextTokens: textOutTokens, + AudioTokens: audioOutTokens, + }, + ModelName: modelName, + UsePrice: relayInfo.UsePrice, + ModelRatio: modelRatio, + GroupRatio: actualGroupRatio, + } + + quota := calculateAudioQuota(quotaInfo) + + if userQuota < quota { + return fmt.Errorf("user quota is not enough, user quota: %s, need quota: %s", logger.FormatQuota(userQuota), logger.FormatQuota(quota)) + } + + if !token.UnlimitedQuota && token.RemainQuota < quota { + return fmt.Errorf("token quota is not enough, token remain quota: %s, need quota: %s", logger.FormatQuota(token.RemainQuota), logger.FormatQuota(quota)) + } + + err = PostConsumeQuota(relayInfo, quota, 0, false) + if err != nil { + return err + } + logger.LogInfo(ctx, "realtime streaming consume quota success, quota: "+fmt.Sprintf("%d", quota)) + return nil +} + +func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string, + usage *dto.RealtimeUsage, extraContent string) { + + useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() + textInputTokens := usage.InputTokenDetails.TextTokens + textOutTokens := usage.OutputTokenDetails.TextTokens + + audioInputTokens := usage.InputTokenDetails.AudioTokens + audioOutTokens := usage.OutputTokenDetails.AudioTokens + + tokenName := ctx.GetString("token_name") + completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(modelName)) + audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(relayInfo.OriginModelName)) + audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(modelName)) + + modelRatio := relayInfo.PriceData.ModelRatio + groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio + modelPrice := relayInfo.PriceData.ModelPrice + usePrice := relayInfo.PriceData.UsePrice + + quotaInfo := QuotaInfo{ + InputDetails: TokenDetails{ + TextTokens: textInputTokens, + AudioTokens: audioInputTokens, + }, + OutputDetails: TokenDetails{ + TextTokens: textOutTokens, + AudioTokens: audioOutTokens, + }, + ModelName: modelName, + UsePrice: usePrice, + ModelRatio: modelRatio, + GroupRatio: groupRatio, + } + + quota := calculateAudioQuota(quotaInfo) + + totalTokens := usage.TotalTokens + var logContent string + if !usePrice { + logContent = fmt.Sprintf("模型倍率 %.2f,补全倍率 %.2f,音频倍率 %.2f,音频补全倍率 %.2f,分组倍率 %.2f", + modelRatio, completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), groupRatio) + } else { + logContent = fmt.Sprintf("模型价格 %.2f,分组倍率 %.2f", modelPrice, groupRatio) + } + + // record all the consume log even if quota is 0 + if totalTokens == 0 { + // in this case, must be some error happened + // we cannot just return, because we may have to return the pre-consumed quota + quota = 0 + logContent += fmt.Sprintf("(可能是上游超时)") + logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+ + "tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota)) + } else { + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota) + model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota) + } + + logModel := modelName + if extraContent != "" { + logContent += ", " + extraContent + } + other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio, + completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio) + model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ + ChannelId: relayInfo.ChannelId, + PromptTokens: usage.InputTokens, + CompletionTokens: usage.OutputTokens, + ModelName: logModel, + TokenName: tokenName, + Quota: quota, + Content: logContent, + TokenId: relayInfo.TokenId, + UseTimeSeconds: int(useTimeSeconds), + IsStream: relayInfo.IsStream, + Group: relayInfo.UsingGroup, + Other: other, + }) +} + +func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) { + + useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() + promptTokens := usage.PromptTokens + completionTokens := usage.CompletionTokens + modelName := relayInfo.OriginModelName + + tokenName := ctx.GetString("token_name") + completionRatio := relayInfo.PriceData.CompletionRatio + modelRatio := relayInfo.PriceData.ModelRatio + groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio + modelPrice := relayInfo.PriceData.ModelPrice + cacheRatio := relayInfo.PriceData.CacheRatio + cacheTokens := usage.PromptTokensDetails.CachedTokens + + cacheCreationRatio := relayInfo.PriceData.CacheCreationRatio + cacheCreationRatio5m := relayInfo.PriceData.CacheCreation5mRatio + cacheCreationRatio1h := relayInfo.PriceData.CacheCreation1hRatio + cacheCreationTokens := usage.PromptTokensDetails.CachedCreationTokens + cacheCreationTokens5m := usage.ClaudeCacheCreation5mTokens + cacheCreationTokens1h := usage.ClaudeCacheCreation1hTokens + + if relayInfo.ChannelType == constant.ChannelTypeOpenRouter { + promptTokens -= cacheTokens + isUsingCustomSettings := relayInfo.PriceData.UsePrice || hasCustomModelRatio(modelName, relayInfo.PriceData.ModelRatio) + if cacheCreationTokens == 0 && relayInfo.PriceData.CacheCreationRatio != 1 && usage.Cost != 0 && !isUsingCustomSettings { + maybeCacheCreationTokens := CalcOpenRouterCacheCreateTokens(*usage, relayInfo.PriceData) + if maybeCacheCreationTokens >= 0 && promptTokens >= maybeCacheCreationTokens { + cacheCreationTokens = maybeCacheCreationTokens + } + } + promptTokens -= cacheCreationTokens + } + + calculateQuota := 0.0 + if !relayInfo.PriceData.UsePrice { + calculateQuota = float64(promptTokens) + calculateQuota += float64(cacheTokens) * cacheRatio + calculateQuota += float64(cacheCreationTokens5m) * cacheCreationRatio5m + calculateQuota += float64(cacheCreationTokens1h) * cacheCreationRatio1h + remainingCacheCreationTokens := cacheCreationTokens - cacheCreationTokens5m - cacheCreationTokens1h + if remainingCacheCreationTokens > 0 { + calculateQuota += float64(remainingCacheCreationTokens) * cacheCreationRatio + } + calculateQuota += float64(completionTokens) * completionRatio + calculateQuota = calculateQuota * groupRatio * modelRatio + } else { + calculateQuota = modelPrice * common.QuotaPerUnit * groupRatio + } + + if modelRatio != 0 && calculateQuota <= 0 { + calculateQuota = 1 + } + + quota := int(calculateQuota) + + totalTokens := promptTokens + completionTokens + + var logContent string + // record all the consume log even if quota is 0 + if totalTokens == 0 { + // in this case, must be some error happened + // we cannot just return, because we may have to return the pre-consumed quota + quota = 0 + logContent += fmt.Sprintf("(可能是上游出错)") + logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+ + "tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota)) + } else { + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota) + model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota) + } + + quotaDelta := quota - relayInfo.FinalPreConsumedQuota + + if quotaDelta > 0 { + logger.LogInfo(ctx, fmt.Sprintf("预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)", + logger.FormatQuota(quotaDelta), + logger.FormatQuota(quota), + logger.FormatQuota(relayInfo.FinalPreConsumedQuota), + )) + } else if quotaDelta < 0 { + logger.LogInfo(ctx, fmt.Sprintf("预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)", + logger.FormatQuota(-quotaDelta), + logger.FormatQuota(quota), + logger.FormatQuota(relayInfo.FinalPreConsumedQuota), + )) + } + + if quotaDelta != 0 { + err := PostConsumeQuota(relayInfo, quotaDelta, relayInfo.FinalPreConsumedQuota, true) + if err != nil { + logger.LogError(ctx, "error consuming token remain quota: "+err.Error()) + } + } + + other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, + cacheTokens, cacheRatio, + cacheCreationTokens, cacheCreationRatio, + cacheCreationTokens5m, cacheCreationRatio5m, + cacheCreationTokens1h, cacheCreationRatio1h, + modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio) + model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ + ChannelId: relayInfo.ChannelId, + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + ModelName: modelName, + TokenName: tokenName, + Quota: quota, + Content: logContent, + TokenId: relayInfo.TokenId, + UseTimeSeconds: int(useTimeSeconds), + IsStream: relayInfo.IsStream, + Group: relayInfo.UsingGroup, + Other: other, + }) + +} + +func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData) int { + if priceData.CacheCreationRatio == 1 { + return 0 + } + quotaPrice := priceData.ModelRatio / common.QuotaPerUnit + promptCacheCreatePrice := quotaPrice * priceData.CacheCreationRatio + promptCacheReadPrice := quotaPrice * priceData.CacheRatio + completionPrice := quotaPrice * priceData.CompletionRatio + + cost, _ := usage.Cost.(float64) + totalPromptTokens := float64(usage.PromptTokens) + completionTokens := float64(usage.CompletionTokens) + promptCacheReadTokens := float64(usage.PromptTokensDetails.CachedTokens) + + return int(math.Round((cost - + totalPromptTokens*quotaPrice + + promptCacheReadTokens*(quotaPrice-promptCacheReadPrice) - + completionTokens*completionPrice) / + (promptCacheCreatePrice - quotaPrice))) +} + +func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) { + + useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() + textInputTokens := usage.PromptTokensDetails.TextTokens + textOutTokens := usage.CompletionTokenDetails.TextTokens + + audioInputTokens := usage.PromptTokensDetails.AudioTokens + audioOutTokens := usage.CompletionTokenDetails.AudioTokens + + tokenName := ctx.GetString("token_name") + completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(relayInfo.OriginModelName)) + audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(relayInfo.OriginModelName)) + audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(relayInfo.OriginModelName)) + + modelRatio := relayInfo.PriceData.ModelRatio + groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio + modelPrice := relayInfo.PriceData.ModelPrice + usePrice := relayInfo.PriceData.UsePrice + + quotaInfo := QuotaInfo{ + InputDetails: TokenDetails{ + TextTokens: textInputTokens, + AudioTokens: audioInputTokens, + }, + OutputDetails: TokenDetails{ + TextTokens: textOutTokens, + AudioTokens: audioOutTokens, + }, + ModelName: relayInfo.OriginModelName, + UsePrice: usePrice, + ModelRatio: modelRatio, + GroupRatio: groupRatio, + } + + quota := calculateAudioQuota(quotaInfo) + + totalTokens := usage.TotalTokens + var logContent string + if !usePrice { + logContent = fmt.Sprintf("模型倍率 %.2f,补全倍率 %.2f,音频倍率 %.2f,音频补全倍率 %.2f,分组倍率 %.2f", + modelRatio, completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), groupRatio) + } else { + logContent = fmt.Sprintf("模型价格 %.2f,分组倍率 %.2f", modelPrice, groupRatio) + } + + // record all the consume log even if quota is 0 + if totalTokens == 0 { + // in this case, must be some error happened + // we cannot just return, because we may have to return the pre-consumed quota + quota = 0 + logContent += fmt.Sprintf("(可能是上游超时)") + logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+ + "tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, relayInfo.OriginModelName, relayInfo.FinalPreConsumedQuota)) + } else { + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota) + model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota) + } + + quotaDelta := quota - relayInfo.FinalPreConsumedQuota + + if quotaDelta > 0 { + logger.LogInfo(ctx, fmt.Sprintf("预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)", + logger.FormatQuota(quotaDelta), + logger.FormatQuota(quota), + logger.FormatQuota(relayInfo.FinalPreConsumedQuota), + )) + } else if quotaDelta < 0 { + logger.LogInfo(ctx, fmt.Sprintf("预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)", + logger.FormatQuota(-quotaDelta), + logger.FormatQuota(quota), + logger.FormatQuota(relayInfo.FinalPreConsumedQuota), + )) + } + + if quotaDelta != 0 { + err := PostConsumeQuota(relayInfo, quotaDelta, relayInfo.FinalPreConsumedQuota, true) + if err != nil { + logger.LogError(ctx, "error consuming token remain quota: "+err.Error()) + } + } + + logModel := relayInfo.OriginModelName + if extraContent != "" { + logContent += ", " + extraContent + } + other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio, + completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio) + model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ + ChannelId: relayInfo.ChannelId, + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + ModelName: logModel, + TokenName: tokenName, + Quota: quota, + Content: logContent, + TokenId: relayInfo.TokenId, + UseTimeSeconds: int(useTimeSeconds), + IsStream: relayInfo.IsStream, + Group: relayInfo.UsingGroup, + Other: other, + }) +} + +func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error { + if quota < 0 { + return errors.New("quota 不能为负数!") + } + if relayInfo.IsPlayground { + return nil + } + //if relayInfo.TokenUnlimited { + // return nil + //} + token, err := model.GetTokenByKey(relayInfo.TokenKey, false) + if err != nil { + return err + } + if !relayInfo.TokenUnlimited && token.RemainQuota < quota { + return fmt.Errorf("token quota is not enough, token remain quota: %s, need quota: %s", logger.FormatQuota(token.RemainQuota), logger.FormatQuota(quota)) + } + err = model.DecreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, quota) + if err != nil { + return err + } + return nil +} + +func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int, sendEmail bool) (err error) { + + if quota > 0 { + err = model.DecreaseUserQuota(relayInfo.UserId, quota) + } else { + err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false) + } + if err != nil { + return err + } + + if !relayInfo.IsPlayground { + if quota > 0 { + err = model.DecreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, quota) + } else { + err = model.IncreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, -quota) + } + if err != nil { + return err + } + } + + if sendEmail { + if (quota + preConsumedQuota) != 0 { + checkAndSendQuotaNotify(relayInfo, quota, preConsumedQuota) + } + } + + return nil +} + +func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int) { + gopool.Go(func() { + userSetting := relayInfo.UserSetting + threshold := common.QuotaRemindThreshold + if userSetting.QuotaWarningThreshold != 0 { + threshold = int(userSetting.QuotaWarningThreshold) + } + + //noMoreQuota := userCache.Quota-(quota+preConsumedQuota) <= 0 + quotaTooLow := false + consumeQuota := quota + preConsumedQuota + if relayInfo.UserQuota-consumeQuota < threshold { + quotaTooLow = true + } + if quotaTooLow { + prompt := "您的额度即将用尽" + topUpLink := fmt.Sprintf("%s/console/topup", system_setting.ServerAddress) + + // 根据通知方式生成不同的内容格式 + var content string + var values []interface{} + + notifyType := userSetting.NotifyType + if notifyType == "" { + notifyType = dto.NotifyTypeEmail + } + + if notifyType == dto.NotifyTypeBark { + // Bark推送使用简短文本,不支持HTML + content = "{{value}},剩余额度:{{value}},请及时充值" + values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} + } else if notifyType == dto.NotifyTypeGotify { + content = "{{value}},当前剩余额度为 {{value}},请及时充值。" + values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} + } else { + // 默认内容格式,适用于Email和Webhook(支持HTML) + content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。
充值链接:{{value}}" + values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink} + } + + err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, values)) + if err != nil { + common.SysError(fmt.Sprintf("failed to send quota notify to user %d: %s", relayInfo.UserId, err.Error())) + } + } + }) +} diff --git a/service/sensitive.go b/service/sensitive.go new file mode 100644 index 0000000000000000000000000000000000000000..3c7809980e3587e5494a7b8e69396775ac78862e --- /dev/null +++ b/service/sensitive.go @@ -0,0 +1,77 @@ +package service + +import ( + "errors" + "strings" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/setting" +) + +func CheckSensitiveMessages(messages []dto.Message) ([]string, error) { + if len(messages) == 0 { + return nil, nil + } + + for _, message := range messages { + arrayContent := message.ParseContent() + for _, m := range arrayContent { + if m.Type == "image_url" { + // TODO: check image url + continue + } + // 检查 text 是否为空 + if m.Text == "" { + continue + } + if ok, words := SensitiveWordContains(m.Text); ok { + return words, errors.New("sensitive words detected") + } + } + } + return nil, nil +} + +func CheckSensitiveText(text string) (bool, []string) { + return SensitiveWordContains(text) +} + +// SensitiveWordContains 是否包含敏感词,返回是否包含敏感词和敏感词列表 +func SensitiveWordContains(text string) (bool, []string) { + if len(setting.SensitiveWords) == 0 { + return false, nil + } + if len(text) == 0 { + return false, nil + } + checkText := strings.ToLower(text) + return AcSearch(checkText, setting.SensitiveWords, true) +} + +// SensitiveWordReplace 敏感词替换,返回是否包含敏感词和替换后的文本 +func SensitiveWordReplace(text string, returnImmediately bool) (bool, []string, string) { + if len(setting.SensitiveWords) == 0 { + return false, nil, text + } + checkText := strings.ToLower(text) + m := getOrBuildAC(setting.SensitiveWords) + hits := m.MultiPatternSearch([]rune(checkText), returnImmediately) + if len(hits) > 0 { + words := make([]string, 0, len(hits)) + var builder strings.Builder + builder.Grow(len(text)) + lastPos := 0 + + for _, hit := range hits { + pos := hit.Pos + word := string(hit.Word) + builder.WriteString(text[lastPos:pos]) + builder.WriteString("**###**") + lastPos = pos + len(word) + words = append(words, word) + } + builder.WriteString(text[lastPos:]) + return true, words, builder.String() + } + return false, nil, text +} diff --git a/service/str.go b/service/str.go new file mode 100644 index 0000000000000000000000000000000000000000..61054bdc46a28a28fa6b7a6c9b83a0eba6e543d8 --- /dev/null +++ b/service/str.go @@ -0,0 +1,152 @@ +package service + +import ( + "bytes" + "fmt" + "hash/fnv" + "sort" + "strings" + "sync" + + goahocorasick "github.com/anknown/ahocorasick" +) + +func SundaySearch(text string, pattern string) bool { + // 计算偏移表 + offset := make(map[rune]int) + for i, c := range pattern { + offset[c] = len(pattern) - i + } + + // 文本串长度和模式串长度 + n, m := len(text), len(pattern) + + // 主循环,i表示当前对齐的文本串位置 + for i := 0; i <= n-m; { + // 检查子串 + j := 0 + for j < m && text[i+j] == pattern[j] { + j++ + } + // 如果完全匹配,返回匹配位置 + if j == m { + return true + } + + // 如果还有剩余字符,则检查下一位字符在偏移表中的值 + if i+m < n { + next := rune(text[i+m]) + if val, ok := offset[next]; ok { + i += val // 存在于偏移表中,进行跳跃 + } else { + i += len(pattern) + 1 // 不存在于偏移表中,跳过整个模式串长度 + } + } else { + break + } + } + return false // 如果没有找到匹配,返回-1 +} + +func RemoveDuplicate(s []string) []string { + result := make([]string, 0, len(s)) + temp := map[string]struct{}{} + for _, item := range s { + if _, ok := temp[item]; !ok { + temp[item] = struct{}{} + result = append(result, item) + } + } + return result +} + +func InitAc(dict []string) *goahocorasick.Machine { + m := new(goahocorasick.Machine) + runes := readRunes(dict) + if err := m.Build(runes); err != nil { + fmt.Println(err) + return nil + } + return m +} + +var acCache sync.Map + +func acKey(dict []string) string { + if len(dict) == 0 { + return "" + } + normalized := make([]string, 0, len(dict)) + for _, w := range dict { + w = strings.ToLower(strings.TrimSpace(w)) + if w != "" { + normalized = append(normalized, w) + } + } + if len(normalized) == 0 { + return "" + } + sort.Strings(normalized) + hasher := fnv.New64a() + for _, w := range normalized { + hasher.Write([]byte{0}) + hasher.Write([]byte(w)) + } + return fmt.Sprintf("%x", hasher.Sum64()) +} + +func getOrBuildAC(dict []string) *goahocorasick.Machine { + key := acKey(dict) + if key == "" { + return nil + } + if v, ok := acCache.Load(key); ok { + if m, ok2 := v.(*goahocorasick.Machine); ok2 { + return m + } + } + m := InitAc(dict) + if m == nil { + return nil + } + if actual, loaded := acCache.LoadOrStore(key, m); loaded { + if cached, ok := actual.(*goahocorasick.Machine); ok { + return cached + } + } + return m +} + +func readRunes(dict []string) [][]rune { + var runes [][]rune + + for _, word := range dict { + word = strings.ToLower(word) + l := bytes.TrimSpace([]byte(word)) + runes = append(runes, bytes.Runes(l)) + } + + return runes +} + +func AcSearch(findText string, dict []string, stopImmediately bool) (bool, []string) { + if len(dict) == 0 { + return false, nil + } + if len(findText) == 0 { + return false, nil + } + m := getOrBuildAC(dict) + if m == nil { + return false, nil + } + hits := m.MultiPatternSearch([]rune(findText), stopImmediately) + if len(hits) > 0 { + words := make([]string, 0) + for _, hit := range hits { + words = append(words, string(hit.Word)) + } + return true, words + } + return false, nil +} diff --git a/service/task.go b/service/task.go new file mode 100644 index 0000000000000000000000000000000000000000..b33ef29c519bf0cc211ac6277334286674a3ca4f --- /dev/null +++ b/service/task.go @@ -0,0 +1,11 @@ +package service + +import ( + "strings" + + "github.com/QuantumNous/new-api/constant" +) + +func CoverTaskActionToModelName(platform constant.TaskPlatform, action string) string { + return strings.ToLower(string(platform)) + "_" + strings.ToLower(action) +} diff --git a/service/token_counter.go b/service/token_counter.go new file mode 100644 index 0000000000000000000000000000000000000000..e4bd1a3ccdb0467ae98c39779345f047538e5839 --- /dev/null +++ b/service/token_counter.go @@ -0,0 +1,641 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "log" + "math" + "path/filepath" + "strings" + "sync" + "unicode/utf8" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + constant2 "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" + "github.com/tiktoken-go/tokenizer" + "github.com/tiktoken-go/tokenizer/codec" +) + +// tokenEncoderMap won't grow after initialization +var defaultTokenEncoder tokenizer.Codec + +// tokenEncoderMap is used to store token encoders for different models +var tokenEncoderMap = make(map[string]tokenizer.Codec) + +// tokenEncoderMutex protects tokenEncoderMap for concurrent access +var tokenEncoderMutex sync.RWMutex + +func InitTokenEncoders() { + common.SysLog("initializing token encoders") + defaultTokenEncoder = codec.NewCl100kBase() + common.SysLog("token encoders initialized") +} + +func getTokenEncoder(model string) tokenizer.Codec { + // First, try to get the encoder from cache with read lock + tokenEncoderMutex.RLock() + if encoder, exists := tokenEncoderMap[model]; exists { + tokenEncoderMutex.RUnlock() + return encoder + } + tokenEncoderMutex.RUnlock() + + // If not in cache, create new encoder with write lock + tokenEncoderMutex.Lock() + defer tokenEncoderMutex.Unlock() + + // Double-check if another goroutine already created the encoder + if encoder, exists := tokenEncoderMap[model]; exists { + return encoder + } + + // Create new encoder + modelCodec, err := tokenizer.ForModel(tokenizer.Model(model)) + if err != nil { + // Cache the default encoder for this model to avoid repeated failures + tokenEncoderMap[model] = defaultTokenEncoder + return defaultTokenEncoder + } + + // Cache the new encoder + tokenEncoderMap[model] = modelCodec + return modelCodec +} + +func getTokenNum(tokenEncoder tokenizer.Codec, text string) int { + if text == "" { + return 0 + } + tkm, _ := tokenEncoder.Count(text) + return tkm +} + +func getImageToken(fileMeta *types.FileMeta, model string, stream bool) (int, error) { + if fileMeta == nil { + return 0, fmt.Errorf("image_url_is_nil") + } + + // Defaults for 4o/4.1/4.5 family unless overridden below + baseTokens := 85 + tileTokens := 170 + + // Model classification + lowerModel := strings.ToLower(model) + + // Special cases from existing behavior + if strings.HasPrefix(lowerModel, "glm-4") { + return 1047, nil + } + + // Patch-based models (32x32 patches, capped at 1536, with multiplier) + isPatchBased := false + multiplier := 1.0 + switch { + case strings.Contains(lowerModel, "gpt-4.1-mini"): + isPatchBased = true + multiplier = 1.62 + case strings.Contains(lowerModel, "gpt-4.1-nano"): + isPatchBased = true + multiplier = 2.46 + case strings.HasPrefix(lowerModel, "o4-mini"): + isPatchBased = true + multiplier = 1.72 + case strings.HasPrefix(lowerModel, "gpt-5-mini"): + isPatchBased = true + multiplier = 1.62 + case strings.HasPrefix(lowerModel, "gpt-5-nano"): + isPatchBased = true + multiplier = 2.46 + } + + // Tile-based model tokens and bases per doc + if !isPatchBased { + if strings.HasPrefix(lowerModel, "gpt-4o-mini") { + baseTokens = 2833 + tileTokens = 5667 + } else if strings.HasPrefix(lowerModel, "gpt-5-chat-latest") || (strings.HasPrefix(lowerModel, "gpt-5") && !strings.Contains(lowerModel, "mini") && !strings.Contains(lowerModel, "nano")) { + baseTokens = 70 + tileTokens = 140 + } else if strings.HasPrefix(lowerModel, "o1") || strings.HasPrefix(lowerModel, "o3") || strings.HasPrefix(lowerModel, "o1-pro") { + baseTokens = 75 + tileTokens = 150 + } else if strings.Contains(lowerModel, "computer-use-preview") { + baseTokens = 65 + tileTokens = 129 + } else if strings.Contains(lowerModel, "4.1") || strings.Contains(lowerModel, "4o") || strings.Contains(lowerModel, "4.5") { + baseTokens = 85 + tileTokens = 170 + } + } + + // Respect existing feature flags/short-circuits + if fileMeta.Detail == "low" && !isPatchBased { + return baseTokens, nil + } + + // Whether to count image tokens at all + if !constant.GetMediaToken { + return 3 * baseTokens, nil + } + + if !constant.GetMediaTokenNotStream && !stream { + return 3 * baseTokens, nil + } + // Normalize detail + if fileMeta.Detail == "auto" || fileMeta.Detail == "" { + fileMeta.Detail = "high" + } + + // Decode image to get dimensions + var config image.Config + var err error + var format string + var b64str string + + if fileMeta.ParsedData != nil { + config, format, b64str, err = DecodeBase64ImageData(fileMeta.ParsedData.Base64Data) + } else { + if strings.HasPrefix(fileMeta.OriginData, "http") { + config, format, err = DecodeUrlImageData(fileMeta.OriginData) + } else { + common.SysLog(fmt.Sprintf("decoding image")) + config, format, b64str, err = DecodeBase64ImageData(fileMeta.OriginData) + } + fileMeta.MimeType = format + } + + if err != nil { + return 0, err + } + + if config.Width == 0 || config.Height == 0 { + // not an image + if format != "" && b64str != "" { + // file type + return 3 * baseTokens, nil + } + return 0, errors.New(fmt.Sprintf("fail to decode base64 config: %s", fileMeta.OriginData)) + } + + width := config.Width + height := config.Height + log.Printf("format: %s, width: %d, height: %d", format, width, height) + + if isPatchBased { + // 32x32 patch-based calculation with 1536 cap and model multiplier + ceilDiv := func(a, b int) int { return (a + b - 1) / b } + rawPatchesW := ceilDiv(width, 32) + rawPatchesH := ceilDiv(height, 32) + rawPatches := rawPatchesW * rawPatchesH + if rawPatches > 1536 { + // scale down + area := float64(width * height) + r := math.Sqrt(float64(32*32*1536) / area) + wScaled := float64(width) * r + hScaled := float64(height) * r + // adjust to fit whole number of patches after scaling + adjW := math.Floor(wScaled/32.0) / (wScaled / 32.0) + adjH := math.Floor(hScaled/32.0) / (hScaled / 32.0) + adj := math.Min(adjW, adjH) + if !math.IsNaN(adj) && adj > 0 { + r = r * adj + } + wScaled = float64(width) * r + hScaled = float64(height) * r + patchesW := math.Ceil(wScaled / 32.0) + patchesH := math.Ceil(hScaled / 32.0) + imageTokens := int(patchesW * patchesH) + if imageTokens > 1536 { + imageTokens = 1536 + } + return int(math.Round(float64(imageTokens) * multiplier)), nil + } + // below cap + imageTokens := rawPatches + return int(math.Round(float64(imageTokens) * multiplier)), nil + } + + // Tile-based calculation for 4o/4.1/4.5/o1/o3/etc. + // Step 1: fit within 2048x2048 square + maxSide := math.Max(float64(width), float64(height)) + fitScale := 1.0 + if maxSide > 2048 { + fitScale = maxSide / 2048.0 + } + fitW := int(math.Round(float64(width) / fitScale)) + fitH := int(math.Round(float64(height) / fitScale)) + + // Step 2: scale so that shortest side is exactly 768 + minSide := math.Min(float64(fitW), float64(fitH)) + if minSide == 0 { + return baseTokens, nil + } + shortScale := 768.0 / minSide + finalW := int(math.Round(float64(fitW) * shortScale)) + finalH := int(math.Round(float64(fitH) * shortScale)) + + // Count 512px tiles + tilesW := (finalW + 512 - 1) / 512 + tilesH := (finalH + 512 - 1) / 512 + tiles := tilesW * tilesH + + if common.DebugEnabled { + log.Printf("scaled to: %dx%d, tiles: %d", finalW, finalH, tiles) + } + + return tiles*tileTokens + baseTokens, nil +} + +func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) { + // 是否统计token + if !constant.CountToken { + return 0, nil + } + + if meta == nil { + return 0, errors.New("token count meta is nil") + } + + if info.RelayFormat == types.RelayFormatOpenAIRealtime { + return 0, nil + } + if info.RelayMode == constant2.RelayModeAudioTranscription || info.RelayMode == constant2.RelayModeAudioTranslation { + multiForm, err := common.ParseMultipartFormReusable(c) + if err != nil { + return 0, fmt.Errorf("error parsing multipart form: %v", err) + } + fileHeaders := multiForm.File["file"] + totalAudioToken := 0 + for _, fileHeader := range fileHeaders { + file, err := fileHeader.Open() + if err != nil { + return 0, fmt.Errorf("error opening audio file: %v", err) + } + defer file.Close() + // get ext and io.seeker + ext := filepath.Ext(fileHeader.Filename) + duration, err := common.GetAudioDuration(c.Request.Context(), file, ext) + if err != nil { + return 0, fmt.Errorf("error getting audio duration: %v", err) + } + // 一分钟 1000 token,与 $price / minute 对齐 + totalAudioToken += int(math.Round(math.Ceil(duration) / 60.0 * 1000)) + } + return totalAudioToken, nil + } + + model := common.GetContextKeyString(c, constant.ContextKeyOriginalModel) + tkm := 0 + + if meta.TokenType == types.TokenTypeTextNumber { + tkm += utf8.RuneCountInString(meta.CombineText) + } else { + tkm += CountTextToken(meta.CombineText, model) + } + + if info.RelayFormat == types.RelayFormatOpenAI { + tkm += meta.ToolsCount * 8 + tkm += meta.MessagesCount * 3 // 每条消息的格式化token数量 + tkm += meta.NameCount * 3 + tkm += 3 + } + + shouldFetchFiles := true + + if info.RelayFormat == types.RelayFormatGemini { + shouldFetchFiles = false + } + + // 是否本地计算媒体token数量 + if !constant.GetMediaToken { + shouldFetchFiles = false + } + + // 是否在非流模式下本地计算媒体token数量 + if !constant.GetMediaTokenNotStream && !info.IsStream { + shouldFetchFiles = false + } + + for _, file := range meta.Files { + if strings.HasPrefix(file.OriginData, "http") { + if shouldFetchFiles { + mineType, err := GetFileTypeFromUrl(c, file.OriginData, "token_counter") + if err != nil { + return 0, fmt.Errorf("error getting file base64 from url: %v", err) + } + if strings.HasPrefix(mineType, "image/") { + file.FileType = types.FileTypeImage + } else if strings.HasPrefix(mineType, "video/") { + file.FileType = types.FileTypeVideo + } else if strings.HasPrefix(mineType, "audio/") { + file.FileType = types.FileTypeAudio + } else { + file.FileType = types.FileTypeFile + } + file.MimeType = mineType + } + } else if strings.HasPrefix(file.OriginData, "data:") { + // get mime type from base64 header + parts := strings.SplitN(file.OriginData, ",", 2) + if len(parts) >= 1 { + header := parts[0] + // Extract mime type from "data:mime/type;base64" format + if strings.Contains(header, ":") && strings.Contains(header, ";") { + mimeStart := strings.Index(header, ":") + 1 + mimeEnd := strings.Index(header, ";") + if mimeStart < mimeEnd { + mineType := header[mimeStart:mimeEnd] + if strings.HasPrefix(mineType, "image/") { + file.FileType = types.FileTypeImage + } else if strings.HasPrefix(mineType, "video/") { + file.FileType = types.FileTypeVideo + } else if strings.HasPrefix(mineType, "audio/") { + file.FileType = types.FileTypeAudio + } else { + file.FileType = types.FileTypeFile + } + file.MimeType = mineType + } + } + } + } + } + + for i, file := range meta.Files { + switch file.FileType { + case types.FileTypeImage: + if info.RelayFormat == types.RelayFormatGemini { + tkm += 520 // gemini per input image tokens + } else { + token, err := getImageToken(file, model, info.IsStream) + if err != nil { + return 0, fmt.Errorf("error counting image token, media index[%d], original data[%s], err: %v", i, file.OriginData, err) + } + tkm += token + } + case types.FileTypeAudio: + tkm += 256 + case types.FileTypeVideo: + tkm += 4096 * 2 + case types.FileTypeFile: + tkm += 4096 + default: + tkm += 4096 // Default case for unknown file types + } + } + + common.SetContextKey(c, constant.ContextKeyPromptTokens, tkm) + return tkm, nil +} + +func CountTokenClaudeRequest(request dto.ClaudeRequest, model string) (int, error) { + tkm := 0 + + // Count tokens in messages + msgTokens, err := CountTokenClaudeMessages(request.Messages, model, request.Stream) + if err != nil { + return 0, err + } + tkm += msgTokens + + // Count tokens in system message + if request.System != "" { + systemTokens := CountTokenInput(request.System, model) + tkm += systemTokens + } + + if request.Tools != nil { + // check is array + if tools, ok := request.Tools.([]any); ok { + if len(tools) > 0 { + parsedTools, err1 := common.Any2Type[[]dto.Tool](request.Tools) + if err1 != nil { + return 0, fmt.Errorf("tools: Input should be a valid list: %v", err) + } + toolTokens, err2 := CountTokenClaudeTools(parsedTools, model) + if err2 != nil { + return 0, fmt.Errorf("tools: %v", err) + } + tkm += toolTokens + } + } else { + return 0, errors.New("tools: Input should be a valid list") + } + } + + return tkm, nil +} + +func CountTokenClaudeMessages(messages []dto.ClaudeMessage, model string, stream bool) (int, error) { + tokenEncoder := getTokenEncoder(model) + tokenNum := 0 + + for _, message := range messages { + // Count tokens for role + tokenNum += getTokenNum(tokenEncoder, message.Role) + if message.IsStringContent() { + tokenNum += getTokenNum(tokenEncoder, message.GetStringContent()) + } else { + content, err := message.ParseContent() + if err != nil { + return 0, err + } + for _, mediaMessage := range content { + switch mediaMessage.Type { + case "text": + tokenNum += getTokenNum(tokenEncoder, mediaMessage.GetText()) + case "image": + //imageTokenNum, err := getClaudeImageToken(mediaMsg.Source, model, stream) + //if err != nil { + // return 0, err + //} + tokenNum += 1000 + case "tool_use": + if mediaMessage.Input != nil { + tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name) + inputJSON, _ := json.Marshal(mediaMessage.Input) + tokenNum += getTokenNum(tokenEncoder, string(inputJSON)) + } + case "tool_result": + if mediaMessage.Content != nil { + contentJSON, _ := json.Marshal(mediaMessage.Content) + tokenNum += getTokenNum(tokenEncoder, string(contentJSON)) + } + } + } + } + } + + // Add a constant for message formatting (this may need adjustment based on Claude's exact formatting) + tokenNum += len(messages) * 2 // Assuming 2 tokens per message for formatting + + return tokenNum, nil +} + +func CountTokenClaudeTools(tools []dto.Tool, model string) (int, error) { + tokenEncoder := getTokenEncoder(model) + tokenNum := 0 + + for _, tool := range tools { + tokenNum += getTokenNum(tokenEncoder, tool.Name) + tokenNum += getTokenNum(tokenEncoder, tool.Description) + + schemaJSON, err := json.Marshal(tool.InputSchema) + if err != nil { + return 0, errors.New(fmt.Sprintf("marshal_tool_schema_fail: %s", err.Error())) + } + tokenNum += getTokenNum(tokenEncoder, string(schemaJSON)) + } + + // Add a constant for tool formatting (this may need adjustment based on Claude's exact formatting) + tokenNum += len(tools) * 3 // Assuming 3 tokens per tool for formatting + + return tokenNum, nil +} + +func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent, model string) (int, int, error) { + audioToken := 0 + textToken := 0 + switch request.Type { + case dto.RealtimeEventTypeSessionUpdate: + if request.Session != nil { + msgTokens := CountTextToken(request.Session.Instructions, model) + textToken += msgTokens + } + case dto.RealtimeEventResponseAudioDelta: + // count audio token + atk, err := CountAudioTokenOutput(request.Delta, info.OutputAudioFormat) + if err != nil { + return 0, 0, fmt.Errorf("error counting audio token: %v", err) + } + audioToken += atk + case dto.RealtimeEventResponseAudioTranscriptionDelta, dto.RealtimeEventResponseFunctionCallArgumentsDelta: + // count text token + tkm := CountTextToken(request.Delta, model) + textToken += tkm + case dto.RealtimeEventInputAudioBufferAppend: + // count audio token + atk, err := CountAudioTokenInput(request.Audio, info.InputAudioFormat) + if err != nil { + return 0, 0, fmt.Errorf("error counting audio token: %v", err) + } + audioToken += atk + case dto.RealtimeEventConversationItemCreated: + if request.Item != nil { + switch request.Item.Type { + case "message": + for _, content := range request.Item.Content { + if content.Type == "input_text" { + tokens := CountTextToken(content.Text, model) + textToken += tokens + } + } + } + } + case dto.RealtimeEventTypeResponseDone: + // count tools token + if !info.IsFirstRequest { + if info.RealtimeTools != nil && len(info.RealtimeTools) > 0 { + for _, tool := range info.RealtimeTools { + toolTokens := CountTokenInput(tool, model) + textToken += 8 + textToken += toolTokens + } + } + } + } + return textToken, audioToken, nil +} + +func CountTokenInput(input any, model string) int { + switch v := input.(type) { + case string: + return CountTextToken(v, model) + case []string: + text := "" + for _, s := range v { + text += s + } + return CountTextToken(text, model) + case []interface{}: + text := "" + for _, item := range v { + text += fmt.Sprintf("%v", item) + } + return CountTextToken(text, model) + } + return CountTokenInput(fmt.Sprintf("%v", input), model) +} + +func CountTokenStreamChoices(messages []dto.ChatCompletionsStreamResponseChoice, model string) int { + tokens := 0 + for _, message := range messages { + tkm := CountTokenInput(message.Delta.GetContentString(), model) + tokens += tkm + if message.Delta.ToolCalls != nil { + for _, tool := range message.Delta.ToolCalls { + tkm := CountTokenInput(tool.Function.Name, model) + tokens += tkm + tkm = CountTokenInput(tool.Function.Arguments, model) + tokens += tkm + } + } + } + return tokens +} + +func CountTTSToken(text string, model string) int { + if strings.HasPrefix(model, "tts") { + return utf8.RuneCountInString(text) + } else { + return CountTextToken(text, model) + } +} + +func CountAudioTokenInput(audioBase64 string, audioFormat string) (int, error) { + if audioBase64 == "" { + return 0, nil + } + duration, err := parseAudio(audioBase64, audioFormat) + if err != nil { + return 0, err + } + return int(duration / 60 * 100 / 0.06), nil +} + +func CountAudioTokenOutput(audioBase64 string, audioFormat string) (int, error) { + if audioBase64 == "" { + return 0, nil + } + duration, err := parseAudio(audioBase64, audioFormat) + if err != nil { + return 0, err + } + return int(duration / 60 * 200 / 0.24), nil +} + +//func CountAudioToken(sec float64, audioType string) { +// if audioType == "input" { +// +// } +//} + +// CountTextToken 统计文本的token数量,仅当文本包含敏感词,返回错误,同时返回token数量 +func CountTextToken(text string, model string) int { + if text == "" { + return 0 + } + tokenEncoder := getTokenEncoder(model) + return getTokenNum(tokenEncoder, text) +} diff --git a/service/usage_helpr.go b/service/usage_helpr.go new file mode 100644 index 0000000000000000000000000000000000000000..825c9f150b45a9d7f457118fc337a0261c717e2d --- /dev/null +++ b/service/usage_helpr.go @@ -0,0 +1,34 @@ +package service + +import ( + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/gin-gonic/gin" +) + +//func GetPromptTokens(textRequest dto.GeneralOpenAIRequest, relayMode int) (int, error) { +// switch relayMode { +// case constant.RelayModeChatCompletions: +// return CountTokenMessages(textRequest.Messages, textRequest.Model) +// case constant.RelayModeCompletions: +// return CountTokenInput(textRequest.Prompt, textRequest.Model), nil +// case constant.RelayModeModerations: +// return CountTokenInput(textRequest.Input, textRequest.Model), nil +// } +// return 0, errors.New("unknown relay mode") +//} + +func ResponseText2Usage(c *gin.Context, responseText string, modeName string, promptTokens int) *dto.Usage { + common.SetContextKey(c, constant.ContextKeyLocalCountTokens, true) + usage := &dto.Usage{} + usage.PromptTokens = promptTokens + ctkm := CountTextToken(responseText, modeName) + usage.CompletionTokens = ctkm + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + return usage +} + +func ValidUsage(usage *dto.Usage) bool { + return usage != nil && (usage.PromptTokens != 0 || usage.CompletionTokens != 0) +} diff --git a/service/user_notify.go b/service/user_notify.go new file mode 100644 index 0000000000000000000000000000000000000000..cecf46cade4fdb6d0c20f9df191859d9118e41fd --- /dev/null +++ b/service/user_notify.go @@ -0,0 +1,255 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/system_setting" +) + +func NotifyRootUser(t string, subject string, content string) { + user := model.GetRootUser().ToBaseUser() + err := NotifyUser(user.Id, user.Email, user.GetSetting(), dto.NewNotify(t, subject, content, nil)) + if err != nil { + common.SysLog(fmt.Sprintf("failed to notify root user: %s", err.Error())) + } +} + +func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data dto.Notify) error { + notifyType := userSetting.NotifyType + if notifyType == "" { + notifyType = dto.NotifyTypeEmail + } + + // Check notification limit + canSend, err := CheckNotificationLimit(userId, data.Type) + if err != nil { + common.SysLog(fmt.Sprintf("failed to check notification limit: %s", err.Error())) + return err + } + if !canSend { + return fmt.Errorf("notification limit exceeded for user %d with type %s", userId, notifyType) + } + + switch notifyType { + case dto.NotifyTypeEmail: + // 优先使用设置中的通知邮箱,如果为空则使用用户的默认邮箱 + emailToUse := userSetting.NotificationEmail + if emailToUse == "" { + emailToUse = userEmail + } + if emailToUse == "" { + common.SysLog(fmt.Sprintf("user %d has no email, skip sending email", userId)) + return nil + } + return sendEmailNotify(emailToUse, data) + case dto.NotifyTypeWebhook: + webhookURLStr := userSetting.WebhookUrl + if webhookURLStr == "" { + common.SysLog(fmt.Sprintf("user %d has no webhook url, skip sending webhook", userId)) + return nil + } + + // 获取 webhook secret + webhookSecret := userSetting.WebhookSecret + return SendWebhookNotify(webhookURLStr, webhookSecret, data) + case dto.NotifyTypeBark: + barkURL := userSetting.BarkUrl + if barkURL == "" { + common.SysLog(fmt.Sprintf("user %d has no bark url, skip sending bark", userId)) + return nil + } + return sendBarkNotify(barkURL, data) + case dto.NotifyTypeGotify: + gotifyUrl := userSetting.GotifyUrl + gotifyToken := userSetting.GotifyToken + if gotifyUrl == "" || gotifyToken == "" { + common.SysLog(fmt.Sprintf("user %d has no gotify url or token, skip sending gotify", userId)) + return nil + } + return sendGotifyNotify(gotifyUrl, gotifyToken, userSetting.GotifyPriority, data) + } + return nil +} + +func sendEmailNotify(userEmail string, data dto.Notify) error { + // make email content + content := data.Content + // 处理占位符 + for _, value := range data.Values { + content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1) + } + return common.SendEmail(data.Title, userEmail, content) +} + +func sendBarkNotify(barkURL string, data dto.Notify) error { + // 处理占位符 + content := data.Content + for _, value := range data.Values { + content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1) + } + + // 替换模板变量 + finalURL := strings.ReplaceAll(barkURL, "{{title}}", url.QueryEscape(data.Title)) + finalURL = strings.ReplaceAll(finalURL, "{{content}}", url.QueryEscape(content)) + + // 发送GET请求到Bark + var req *http.Request + var resp *http.Response + var err error + + if system_setting.EnableWorker() { + // 使用worker发送请求 + workerReq := &WorkerRequest{ + URL: finalURL, + Key: system_setting.WorkerValidKey, + Method: http.MethodGet, + Headers: map[string]string{ + "User-Agent": "OneAPI-Bark-Notify/1.0", + }, + } + + resp, err = DoWorkerRequest(workerReq) + if err != nil { + return fmt.Errorf("failed to send bark request through worker: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode) + } + } else { + // SSRF防护:验证Bark URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return fmt.Errorf("request reject: %v", err) + } + + // 直接发送请求 + req, err = http.NewRequest(http.MethodGet, finalURL, nil) + if err != nil { + return fmt.Errorf("failed to create bark request: %v", err) + } + + // 设置User-Agent + req.Header.Set("User-Agent", "OneAPI-Bark-Notify/1.0") + + // 发送请求 + client := GetHttpClient() + resp, err = client.Do(req) + if err != nil { + return fmt.Errorf("failed to send bark request: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode) + } + } + + return nil +} + +func sendGotifyNotify(gotifyUrl string, gotifyToken string, priority int, data dto.Notify) error { + // 处理占位符 + content := data.Content + for _, value := range data.Values { + content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1) + } + + // 构建完整的 Gotify API URL + // 确保 URL 以 /message 结尾 + finalURL := strings.TrimSuffix(gotifyUrl, "/") + "/message?token=" + url.QueryEscape(gotifyToken) + + // Gotify优先级范围0-10,如果超出范围则使用默认值5 + if priority < 0 || priority > 10 { + priority = 5 + } + + // 构建 JSON payload + type GotifyMessage struct { + Title string `json:"title"` + Message string `json:"message"` + Priority int `json:"priority"` + } + + payload := GotifyMessage{ + Title: data.Title, + Message: content, + Priority: priority, + } + + // 序列化为 JSON + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal gotify payload: %v", err) + } + + var req *http.Request + var resp *http.Response + + if system_setting.EnableWorker() { + // 使用worker发送请求 + workerReq := &WorkerRequest{ + URL: finalURL, + Key: system_setting.WorkerValidKey, + Method: http.MethodPost, + Headers: map[string]string{ + "Content-Type": "application/json; charset=utf-8", + "User-Agent": "OneAPI-Gotify-Notify/1.0", + }, + Body: payloadBytes, + } + + resp, err = DoWorkerRequest(workerReq) + if err != nil { + return fmt.Errorf("failed to send gotify request through worker: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode) + } + } else { + // SSRF防护:验证Gotify URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return fmt.Errorf("request reject: %v", err) + } + + // 直接发送请求 + req, err = http.NewRequest(http.MethodPost, finalURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + return fmt.Errorf("failed to create gotify request: %v", err) + } + + // 设置请求头 + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("User-Agent", "NewAPI-Gotify-Notify/1.0") + + // 发送请求 + client := GetHttpClient() + resp, err = client.Do(req) + if err != nil { + return fmt.Errorf("failed to send gotify request: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode) + } + } + + return nil +} diff --git a/service/webhook.go b/service/webhook.go new file mode 100644 index 0000000000000000000000000000000000000000..bab8842c82e7452eb0d48ea80d44c5c46b0c7611 --- /dev/null +++ b/service/webhook.go @@ -0,0 +1,126 @@ +package service + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/setting/system_setting" +) + +// WebhookPayload webhook 通知的负载数据 +type WebhookPayload struct { + Type string `json:"type"` + Title string `json:"title"` + Content string `json:"content"` + Values []interface{} `json:"values,omitempty"` + Timestamp int64 `json:"timestamp"` +} + +// generateSignature 生成 webhook 签名 +func generateSignature(secret string, payload []byte) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write(payload) + return hex.EncodeToString(h.Sum(nil)) +} + +// SendWebhookNotify 发送 webhook 通知 +func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error { + // 处理占位符 + content := data.Content + for _, value := range data.Values { + content = fmt.Sprintf(content, value) + } + + // 构建 webhook 负载 + payload := WebhookPayload{ + Type: data.Type, + Title: data.Title, + Content: content, + Values: data.Values, + Timestamp: time.Now().Unix(), + } + + // 序列化负载 + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal webhook payload: %v", err) + } + + // 创建 HTTP 请求 + var req *http.Request + var resp *http.Response + + if system_setting.EnableWorker() { + // 构建worker请求数据 + workerReq := &WorkerRequest{ + URL: webhookURL, + Key: system_setting.WorkerValidKey, + Method: http.MethodPost, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + Body: payloadBytes, + } + + // 如果有secret,添加签名到headers + if secret != "" { + signature := generateSignature(secret, payloadBytes) + workerReq.Headers["X-Webhook-Signature"] = signature + workerReq.Headers["Authorization"] = "Bearer " + secret + } + + resp, err = DoWorkerRequest(workerReq) + if err != nil { + return fmt.Errorf("failed to send webhook request through worker: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode) + } + } else { + // SSRF防护:验证Webhook URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return fmt.Errorf("request reject: %v", err) + } + + req, err = http.NewRequest(http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + return fmt.Errorf("failed to create webhook request: %v", err) + } + + // 设置请求头 + req.Header.Set("Content-Type", "application/json") + + // 如果有 secret,生成签名 + if secret != "" { + signature := generateSignature(secret, payloadBytes) + req.Header.Set("X-Webhook-Signature", signature) + } + + // 发送请求 + client := GetHttpClient() + resp, err = client.Do(req) + if err != nil { + return fmt.Errorf("failed to send webhook request: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode) + } + } + + return nil +} diff --git a/setting/auto_group.go b/setting/auto_group.go new file mode 100644 index 0000000000000000000000000000000000000000..9261286bca93d2800d4005a62a7bd298057a0f4b --- /dev/null +++ b/setting/auto_group.go @@ -0,0 +1,37 @@ +package setting + +import ( + "github.com/QuantumNous/new-api/common" +) + +var autoGroups = []string{ + "default", +} + +var DefaultUseAutoGroup = false + +func ContainsAutoGroup(group string) bool { + for _, autoGroup := range autoGroups { + if autoGroup == group { + return true + } + } + return false +} + +func UpdateAutoGroupsByJsonString(jsonString string) error { + autoGroups = make([]string, 0) + return common.Unmarshal([]byte(jsonString), &autoGroups) +} + +func AutoGroups2JsonString() string { + jsonBytes, err := common.Marshal(autoGroups) + if err != nil { + return "[]" + } + return string(jsonBytes) +} + +func GetAutoGroups() []string { + return autoGroups +} diff --git a/setting/chat.go b/setting/chat.go new file mode 100644 index 0000000000000000000000000000000000000000..9be08268a8110f1ba52e3ea7d8093c2510b52db7 --- /dev/null +++ b/setting/chat.go @@ -0,0 +1,45 @@ +package setting + +import ( + "encoding/json" + + "github.com/QuantumNous/new-api/common" +) + +var Chats = []map[string]string{ + //{ + // "ChatGPT Next Web 官方示例": "https://app.nextchat.dev/#/?settings={\"key\":\"{key}\",\"url\":\"{address}\"}", + //}, + { + "Cherry Studio": "cherrystudio://providers/api-keys?v=1&data={cherryConfig}", + }, + { + "流畅阅读": "fluentread", + }, + { + "Lobe Chat 官方示例": "https://chat-preview.lobehub.com/?settings={\"keyVaults\":{\"openai\":{\"apiKey\":\"{key}\",\"baseURL\":\"{address}/v1\"}}}", + }, + { + "AI as Workspace": "https://aiaw.app/set-provider?provider={\"type\":\"openai\",\"settings\":{\"apiKey\":\"{key}\",\"baseURL\":\"{address}/v1\",\"compatibility\":\"strict\"}}", + }, + { + "AMA 问天": "ama://set-api-key?server={address}&key={key}", + }, + { + "OpenCat": "opencat://team/join?domain={address}&token={key}", + }, +} + +func UpdateChatsByJsonString(jsonString string) error { + Chats = make([]map[string]string, 0) + return json.Unmarshal([]byte(jsonString), &Chats) +} + +func Chats2JsonString() string { + jsonBytes, err := json.Marshal(Chats) + if err != nil { + common.SysLog("error marshalling chats: " + err.Error()) + return "[]" + } + return string(jsonBytes) +} diff --git a/setting/config/config.go b/setting/config/config.go new file mode 100644 index 0000000000000000000000000000000000000000..6c6abe9d4a9e5f79ad9ba02169ad3e47a488553d --- /dev/null +++ b/setting/config/config.go @@ -0,0 +1,287 @@ +package config + +import ( + "encoding/json" + "reflect" + "strconv" + "strings" + "sync" + + "github.com/QuantumNous/new-api/common" +) + +// ConfigManager 统一管理所有配置 +type ConfigManager struct { + configs map[string]interface{} + mutex sync.RWMutex +} + +var GlobalConfig = NewConfigManager() + +func NewConfigManager() *ConfigManager { + return &ConfigManager{ + configs: make(map[string]interface{}), + } +} + +// Register 注册一个配置模块 +func (cm *ConfigManager) Register(name string, config interface{}) { + cm.mutex.Lock() + defer cm.mutex.Unlock() + cm.configs[name] = config +} + +// Get 获取指定配置模块 +func (cm *ConfigManager) Get(name string) interface{} { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + return cm.configs[name] +} + +// LoadFromDB 从数据库加载配置 +func (cm *ConfigManager) LoadFromDB(options map[string]string) error { + cm.mutex.Lock() + defer cm.mutex.Unlock() + + for name, config := range cm.configs { + prefix := name + "." + configMap := make(map[string]string) + + // 收集属于此配置的所有选项 + for key, value := range options { + if strings.HasPrefix(key, prefix) { + configKey := strings.TrimPrefix(key, prefix) + configMap[configKey] = value + } + } + + // 如果找到配置项,则更新配置 + if len(configMap) > 0 { + if err := updateConfigFromMap(config, configMap); err != nil { + common.SysError("failed to update config " + name + ": " + err.Error()) + continue + } + } + } + + return nil +} + +// SaveToDB 将配置保存到数据库 +func (cm *ConfigManager) SaveToDB(updateFunc func(key, value string) error) error { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + + for name, config := range cm.configs { + configMap, err := configToMap(config) + if err != nil { + return err + } + + for key, value := range configMap { + dbKey := name + "." + key + if err := updateFunc(dbKey, value); err != nil { + return err + } + } + } + + return nil +} + +// 辅助函数:将配置对象转换为map +func configToMap(config interface{}) (map[string]string, error) { + result := make(map[string]string) + + val := reflect.ValueOf(config) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + if val.Kind() != reflect.Struct { + return nil, nil + } + + typ := val.Type() + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + + // 跳过未导出字段 + if !fieldType.IsExported() { + continue + } + + // 获取json标签作为键名 + key := fieldType.Tag.Get("json") + if key == "" || key == "-" { + key = fieldType.Name + } + + // 处理不同类型的字段 + var strValue string + switch field.Kind() { + case reflect.String: + strValue = field.String() + case reflect.Bool: + strValue = strconv.FormatBool(field.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + strValue = strconv.FormatInt(field.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + strValue = strconv.FormatUint(field.Uint(), 10) + case reflect.Float32, reflect.Float64: + strValue = strconv.FormatFloat(field.Float(), 'f', -1, 64) + case reflect.Ptr: + // 处理指针类型:如果非 nil,序列化指向的值 + if !field.IsNil() { + bytes, err := json.Marshal(field.Interface()) + if err != nil { + return nil, err + } + strValue = string(bytes) + } else { + // nil 指针序列化为 "null" + strValue = "null" + } + case reflect.Map, reflect.Slice, reflect.Struct: + // 复杂类型使用JSON序列化 + bytes, err := json.Marshal(field.Interface()) + if err != nil { + return nil, err + } + strValue = string(bytes) + default: + // 跳过不支持的类型 + continue + } + + result[key] = strValue + } + + return result, nil +} + +// 辅助函数:从map更新配置对象 +func updateConfigFromMap(config interface{}, configMap map[string]string) error { + val := reflect.ValueOf(config) + if val.Kind() != reflect.Ptr { + return nil + } + val = val.Elem() + + if val.Kind() != reflect.Struct { + return nil + } + + typ := val.Type() + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + + // 跳过未导出字段 + if !fieldType.IsExported() { + continue + } + + // 获取json标签作为键名 + key := fieldType.Tag.Get("json") + if key == "" || key == "-" { + key = fieldType.Name + } + + // 检查map中是否有对应的值 + strValue, ok := configMap[key] + if !ok { + continue + } + + // 根据字段类型设置值 + if !field.CanSet() { + continue + } + + switch field.Kind() { + case reflect.String: + field.SetString(strValue) + case reflect.Bool: + boolValue, err := strconv.ParseBool(strValue) + if err != nil { + continue + } + field.SetBool(boolValue) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + intValue, err := strconv.ParseInt(strValue, 10, 64) + if err != nil { + continue + } + field.SetInt(intValue) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + uintValue, err := strconv.ParseUint(strValue, 10, 64) + if err != nil { + continue + } + field.SetUint(uintValue) + case reflect.Float32, reflect.Float64: + floatValue, err := strconv.ParseFloat(strValue, 64) + if err != nil { + continue + } + field.SetFloat(floatValue) + case reflect.Ptr: + // 处理指针类型 + if strValue == "null" { + field.Set(reflect.Zero(field.Type())) + } else { + // 如果指针是 nil,需要先初始化 + if field.IsNil() { + field.Set(reflect.New(field.Type().Elem())) + } + // 反序列化到指针指向的值 + err := json.Unmarshal([]byte(strValue), field.Interface()) + if err != nil { + continue + } + } + case reflect.Map, reflect.Slice, reflect.Struct: + // 复杂类型使用JSON反序列化 + err := json.Unmarshal([]byte(strValue), field.Addr().Interface()) + if err != nil { + continue + } + } + } + + return nil +} + +// ConfigToMap 将配置对象转换为map(导出函数) +func ConfigToMap(config interface{}) (map[string]string, error) { + return configToMap(config) +} + +// UpdateConfigFromMap 从map更新配置对象(导出函数) +func UpdateConfigFromMap(config interface{}, configMap map[string]string) error { + return updateConfigFromMap(config, configMap) +} + +// ExportAllConfigs 导出所有已注册的配置为扁平结构 +func (cm *ConfigManager) ExportAllConfigs() map[string]string { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + + result := make(map[string]string) + + for name, cfg := range cm.configs { + configMap, err := ConfigToMap(cfg) + if err != nil { + continue + } + + // 使用 "模块名.配置项" 的格式添加到结果中 + for key, value := range configMap { + result[name+"."+key] = value + } + } + + return result +} diff --git a/setting/console_setting/config.go b/setting/console_setting/config.go new file mode 100644 index 0000000000000000000000000000000000000000..144e95c497beeb7a1d3d908ee83c44634b7069af --- /dev/null +++ b/setting/console_setting/config.go @@ -0,0 +1,39 @@ +package console_setting + +import "github.com/QuantumNous/new-api/setting/config" + +type ConsoleSetting struct { + ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串) + UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串) + Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串) + FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串) + ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板 + UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板 + AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板 + FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板 +} + +// 默认配置 +var defaultConsoleSetting = ConsoleSetting{ + ApiInfo: "", + UptimeKumaGroups: "", + Announcements: "", + FAQ: "", + ApiInfoEnabled: true, + UptimeKumaEnabled: true, + AnnouncementsEnabled: true, + FAQEnabled: true, +} + +// 全局实例 +var consoleSetting = defaultConsoleSetting + +func init() { + // 注册到全局配置管理器,键名为 console_setting + config.GlobalConfig.Register("console_setting", &consoleSetting) +} + +// GetConsoleSetting 获取 ConsoleSetting 配置实例 +func GetConsoleSetting() *ConsoleSetting { + return &consoleSetting +} diff --git a/setting/console_setting/validation.go b/setting/console_setting/validation.go new file mode 100644 index 0000000000000000000000000000000000000000..529457761bb0940e835dcf59c2c29af5384e0095 --- /dev/null +++ b/setting/console_setting/validation.go @@ -0,0 +1,304 @@ +package console_setting + +import ( + "encoding/json" + "fmt" + "net/url" + "regexp" + "sort" + "strings" + "time" +) + +var ( + urlRegex = regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?:\:[0-9]{1,5})?(?:/.*)?$`) + dangerousChars = []string{" 50 { + return fmt.Errorf("API信息数量不能超过50个") + } + + for i, apiInfo := range apiInfoList { + urlStr, ok := apiInfo["url"].(string) + if !ok || urlStr == "" { + return fmt.Errorf("第%d个API信息缺少URL字段", i+1) + } + route, ok := apiInfo["route"].(string) + if !ok || route == "" { + return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1) + } + description, ok := apiInfo["description"].(string) + if !ok || description == "" { + return fmt.Errorf("第%d个API信息缺少说明字段", i+1) + } + color, ok := apiInfo["color"].(string) + if !ok || color == "" { + return fmt.Errorf("第%d个API信息缺少颜色字段", i+1) + } + + if err := validateURL(urlStr, i+1, "API信息"); err != nil { + return err + } + + if len(urlStr) > 500 { + return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1) + } + if len(route) > 100 { + return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1) + } + if len(description) > 200 { + return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1) + } + + if !validColors[color] { + return fmt.Errorf("第%d个API信息的颜色值不合法", i+1) + } + + if err := checkDangerousContent(description, i+1, "API信息"); err != nil { + return err + } + if err := checkDangerousContent(route, i+1, "API信息"); err != nil { + return err + } + } + return nil +} + +func GetApiInfo() []map[string]interface{} { + return getJSONList(GetConsoleSetting().ApiInfo) +} + +func validateAnnouncements(announcementsStr string) error { + list, err := parseJSONArray(announcementsStr, "系统公告") + if err != nil { + return err + } + if len(list) > 100 { + return fmt.Errorf("系统公告数量不能超过100个") + } + validTypes := map[string]bool{ + "default": true, "ongoing": true, "success": true, "warning": true, "error": true, + } + for i, ann := range list { + content, ok := ann["content"].(string) + if !ok || content == "" { + return fmt.Errorf("第%d个公告缺少内容字段", i+1) + } + publishDateAny, exists := ann["publishDate"] + if !exists { + return fmt.Errorf("第%d个公告缺少发布日期字段", i+1) + } + publishDateStr, ok := publishDateAny.(string) + if !ok || publishDateStr == "" { + return fmt.Errorf("第%d个公告的发布日期不能为空", i+1) + } + if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil { + return fmt.Errorf("第%d个公告的发布日期格式错误", i+1) + } + if t, exists := ann["type"]; exists { + if typeStr, ok := t.(string); ok { + if !validTypes[typeStr] { + return fmt.Errorf("第%d个公告的类型值不合法", i+1) + } + } + } + if len(content) > 500 { + return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1) + } + if extra, exists := ann["extra"]; exists { + if extraStr, ok := extra.(string); ok && len(extraStr) > 200 { + return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1) + } + } + } + return nil +} + +func validateFAQ(faqStr string) error { + list, err := parseJSONArray(faqStr, "FAQ信息") + if err != nil { + return err + } + if len(list) > 100 { + return fmt.Errorf("FAQ数量不能超过100个") + } + for i, faq := range list { + question, ok := faq["question"].(string) + if !ok || question == "" { + return fmt.Errorf("第%d个FAQ缺少问题字段", i+1) + } + answer, ok := faq["answer"].(string) + if !ok || answer == "" { + return fmt.Errorf("第%d个FAQ缺少答案字段", i+1) + } + if len(question) > 200 { + return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1) + } + if len(answer) > 1000 { + return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1) + } + } + return nil +} + +func getPublishTime(item map[string]interface{}) time.Time { + if v, ok := item["publishDate"]; ok { + if s, ok2 := v.(string); ok2 { + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t + } + } + } + return time.Time{} +} + +func GetAnnouncements() []map[string]interface{} { + list := getJSONList(GetConsoleSetting().Announcements) + sort.SliceStable(list, func(i, j int) bool { + return getPublishTime(list[i]).After(getPublishTime(list[j])) + }) + return list +} + +func GetFAQ() []map[string]interface{} { + return getJSONList(GetConsoleSetting().FAQ) +} + +func validateUptimeKumaGroups(groupsStr string) error { + groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置") + if err != nil { + return err + } + + if len(groups) > 20 { + return fmt.Errorf("Uptime Kuma分组数量不能超过20个") + } + + nameSet := make(map[string]bool) + + for i, group := range groups { + categoryName, ok := group["categoryName"].(string) + if !ok || categoryName == "" { + return fmt.Errorf("第%d个分组缺少分类名称字段", i+1) + } + if nameSet[categoryName] { + return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1) + } + nameSet[categoryName] = true + urlStr, ok := group["url"].(string) + if !ok || urlStr == "" { + return fmt.Errorf("第%d个分组缺少URL字段", i+1) + } + slug, ok := group["slug"].(string) + if !ok || slug == "" { + return fmt.Errorf("第%d个分组缺少Slug字段", i+1) + } + description, ok := group["description"].(string) + if !ok { + description = "" + } + + if err := validateURL(urlStr, i+1, "分组"); err != nil { + return err + } + + if len(categoryName) > 50 { + return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1) + } + if len(urlStr) > 500 { + return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1) + } + if len(slug) > 100 { + return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1) + } + if len(description) > 200 { + return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1) + } + + if !slugRegex.MatchString(slug) { + return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1) + } + + if err := checkDangerousContent(description, i+1, "分组"); err != nil { + return err + } + if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil { + return err + } + } + return nil +} + +func GetUptimeKumaGroups() []map[string]interface{} { + return getJSONList(GetConsoleSetting().UptimeKumaGroups) +} diff --git a/setting/midjourney.go b/setting/midjourney.go new file mode 100644 index 0000000000000000000000000000000000000000..d84f5d752d0448198c07bb44b9fb6f27c41b85cd --- /dev/null +++ b/setting/midjourney.go @@ -0,0 +1,7 @@ +package setting + +var MjNotifyEnabled = false +var MjAccountFilterEnabled = false +var MjModeClearEnabled = false +var MjForwardUrlEnabled = true +var MjActionCheckSuccessEnabled = true diff --git a/setting/model_setting/claude.go b/setting/model_setting/claude.go new file mode 100644 index 0000000000000000000000000000000000000000..aa010c07aaf7ca63565fcc76510eadb0d8cd9391 --- /dev/null +++ b/setting/model_setting/claude.go @@ -0,0 +1,75 @@ +package model_setting + +import ( + "net/http" + + "github.com/QuantumNous/new-api/setting/config" +) + +//var claudeHeadersSettings = map[string][]string{} +// +//var ClaudeThinkingAdapterEnabled = true +//var ClaudeThinkingAdapterMaxTokens = 8192 +//var ClaudeThinkingAdapterBudgetTokensPercentage = 0.8 + +// ClaudeSettings 定义Claude模型的配置 +type ClaudeSettings struct { + HeadersSettings map[string]map[string][]string `json:"model_headers_settings"` + DefaultMaxTokens map[string]int `json:"default_max_tokens"` + ThinkingAdapterEnabled bool `json:"thinking_adapter_enabled"` + ThinkingAdapterBudgetTokensPercentage float64 `json:"thinking_adapter_budget_tokens_percentage"` +} + +// 默认配置 +var defaultClaudeSettings = ClaudeSettings{ + HeadersSettings: map[string]map[string][]string{}, + ThinkingAdapterEnabled: true, + DefaultMaxTokens: map[string]int{ + "default": 8192, + }, + ThinkingAdapterBudgetTokensPercentage: 0.8, +} + +// 全局实例 +var claudeSettings = defaultClaudeSettings + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("claude", &claudeSettings) +} + +// GetClaudeSettings 获取Claude配置 +func GetClaudeSettings() *ClaudeSettings { + // check default max tokens must have default key + if _, ok := claudeSettings.DefaultMaxTokens["default"]; !ok { + claudeSettings.DefaultMaxTokens["default"] = 8192 + } + return &claudeSettings +} + +func (c *ClaudeSettings) WriteHeaders(originModel string, httpHeader *http.Header) { + if headers, ok := c.HeadersSettings[originModel]; ok { + for headerKey, headerValues := range headers { + // get existing values for this header key + existingValues := httpHeader.Values(headerKey) + existingValuesMap := make(map[string]bool) + for _, v := range existingValues { + existingValuesMap[v] = true + } + + // add only values that don't already exist + for _, headerValue := range headerValues { + if !existingValuesMap[headerValue] { + httpHeader.Add(headerKey, headerValue) + } + } + } + } +} + +func (c *ClaudeSettings) GetDefaultMaxTokens(model string) int { + if maxTokens, ok := c.DefaultMaxTokens[model]; ok { + return maxTokens + } + return c.DefaultMaxTokens["default"] +} diff --git a/setting/model_setting/gemini.go b/setting/model_setting/gemini.go new file mode 100644 index 0000000000000000000000000000000000000000..2731dcba49c57f73f820ed24240e7ce1bff070d8 --- /dev/null +++ b/setting/model_setting/gemini.go @@ -0,0 +1,75 @@ +package model_setting + +import ( + "github.com/QuantumNous/new-api/setting/config" +) + +// GeminiSettings 定义Gemini模型的配置 +type GeminiSettings struct { + SafetySettings map[string]string `json:"safety_settings"` + VersionSettings map[string]string `json:"version_settings"` + SupportedImagineModels []string `json:"supported_imagine_models"` + ThinkingAdapterEnabled bool `json:"thinking_adapter_enabled"` + ThinkingAdapterBudgetTokensPercentage float64 `json:"thinking_adapter_budget_tokens_percentage"` + FunctionCallThoughtSignatureEnabled bool `json:"function_call_thought_signature_enabled"` +} + +// 默认配置 +var defaultGeminiSettings = GeminiSettings{ + SafetySettings: map[string]string{ + "default": "OFF", + }, + VersionSettings: map[string]string{ + "default": "v1beta", + "gemini-1.0-pro": "v1", + }, + SupportedImagineModels: []string{ + "gemini-2.0-flash-exp-image-generation", + "gemini-2.0-flash-exp", + "gemini-3-pro-image-preview", + "gemini-2.5-flash-image", + "nano-banana", + "nano-banana-pro", + }, + ThinkingAdapterEnabled: false, + ThinkingAdapterBudgetTokensPercentage: 0.6, + FunctionCallThoughtSignatureEnabled: true, +} + +// 全局实例 +var geminiSettings = defaultGeminiSettings + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("gemini", &geminiSettings) +} + +// GetGeminiSettings 获取Gemini配置 +func GetGeminiSettings() *GeminiSettings { + return &geminiSettings +} + +// GetGeminiSafetySetting 获取安全设置 +func GetGeminiSafetySetting(key string) string { + if value, ok := geminiSettings.SafetySettings[key]; ok { + return value + } + return geminiSettings.SafetySettings["default"] +} + +// GetGeminiVersionSetting 获取版本设置 +func GetGeminiVersionSetting(key string) string { + if value, ok := geminiSettings.VersionSettings[key]; ok { + return value + } + return geminiSettings.VersionSettings["default"] +} + +func IsGeminiModelSupportImagine(model string) bool { + for _, v := range geminiSettings.SupportedImagineModels { + if v == model { + return true + } + } + return false +} diff --git a/setting/model_setting/global.go b/setting/model_setting/global.go new file mode 100644 index 0000000000000000000000000000000000000000..e8815c0e9b812525633979d776e1e7ab90573277 --- /dev/null +++ b/setting/model_setting/global.go @@ -0,0 +1,48 @@ +package model_setting + +import ( + "strings" + + "github.com/QuantumNous/new-api/setting/config" +) + +type GlobalSettings struct { + PassThroughRequestEnabled bool `json:"pass_through_request_enabled"` + ThinkingModelBlacklist []string `json:"thinking_model_blacklist"` +} + +// 默认配置 +var defaultOpenaiSettings = GlobalSettings{ + PassThroughRequestEnabled: false, + ThinkingModelBlacklist: []string{ + "moonshotai/kimi-k2-thinking", + "kimi-k2-thinking", + }, +} + +// 全局实例 +var globalSettings = defaultOpenaiSettings + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("global", &globalSettings) +} + +func GetGlobalSettings() *GlobalSettings { + return &globalSettings +} + +// ShouldPreserveThinkingSuffix 判断模型是否配置为保留 thinking/-nothinking 后缀 +func ShouldPreserveThinkingSuffix(modelName string) bool { + target := strings.TrimSpace(modelName) + if target == "" { + return false + } + + for _, entry := range globalSettings.ThinkingModelBlacklist { + if strings.TrimSpace(entry) == target { + return true + } + } + return false +} diff --git a/setting/operation_setting/general_setting.go b/setting/operation_setting/general_setting.go new file mode 100644 index 0000000000000000000000000000000000000000..b4a3ccccdaf3e2d05a3b35ff5334d6bea772cda6 --- /dev/null +++ b/setting/operation_setting/general_setting.go @@ -0,0 +1,91 @@ +package operation_setting + +import "github.com/QuantumNous/new-api/setting/config" + +// 额度展示类型 +const ( + QuotaDisplayTypeUSD = "USD" + QuotaDisplayTypeCNY = "CNY" + QuotaDisplayTypeTokens = "TOKENS" + QuotaDisplayTypeCustom = "CUSTOM" +) + +type GeneralSetting struct { + DocsLink string `json:"docs_link"` + PingIntervalEnabled bool `json:"ping_interval_enabled"` + PingIntervalSeconds int `json:"ping_interval_seconds"` + // 当前站点额度展示类型:USD / CNY / TOKENS + QuotaDisplayType string `json:"quota_display_type"` + // 自定义货币符号,用于 CUSTOM 展示类型 + CustomCurrencySymbol string `json:"custom_currency_symbol"` + // 自定义货币与美元汇率(1 USD = X Custom) + CustomCurrencyExchangeRate float64 `json:"custom_currency_exchange_rate"` +} + +// 默认配置 +var generalSetting = GeneralSetting{ + DocsLink: "https://docs.newapi.pro", + PingIntervalEnabled: false, + PingIntervalSeconds: 60, + QuotaDisplayType: QuotaDisplayTypeUSD, + CustomCurrencySymbol: "¤", + CustomCurrencyExchangeRate: 1.0, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("general_setting", &generalSetting) +} + +func GetGeneralSetting() *GeneralSetting { + return &generalSetting +} + +// IsCurrencyDisplay 是否以货币形式展示(美元或人民币) +func IsCurrencyDisplay() bool { + return generalSetting.QuotaDisplayType != QuotaDisplayTypeTokens +} + +// IsCNYDisplay 是否以人民币展示 +func IsCNYDisplay() bool { + return generalSetting.QuotaDisplayType == QuotaDisplayTypeCNY +} + +// GetQuotaDisplayType 返回额度展示类型 +func GetQuotaDisplayType() string { + return generalSetting.QuotaDisplayType +} + +// GetCurrencySymbol 返回当前展示类型对应符号 +func GetCurrencySymbol() string { + switch generalSetting.QuotaDisplayType { + case QuotaDisplayTypeUSD: + return "$" + case QuotaDisplayTypeCNY: + return "¥" + case QuotaDisplayTypeCustom: + if generalSetting.CustomCurrencySymbol != "" { + return generalSetting.CustomCurrencySymbol + } + return "¤" + default: + return "" + } +} + +// GetUsdToCurrencyRate 返回 1 USD = X 的 X(TOKENS 不适用) +func GetUsdToCurrencyRate(usdToCny float64) float64 { + switch generalSetting.QuotaDisplayType { + case QuotaDisplayTypeUSD: + return 1 + case QuotaDisplayTypeCNY: + return usdToCny + case QuotaDisplayTypeCustom: + if generalSetting.CustomCurrencyExchangeRate > 0 { + return generalSetting.CustomCurrencyExchangeRate + } + return 1 + default: + return 1 + } +} diff --git a/setting/operation_setting/monitor_setting.go b/setting/operation_setting/monitor_setting.go new file mode 100644 index 0000000000000000000000000000000000000000..541e25f8a105edd9035c41b4efbae7db645ef454 --- /dev/null +++ b/setting/operation_setting/monitor_setting.go @@ -0,0 +1,35 @@ +package operation_setting + +import ( + "os" + "strconv" + + "github.com/QuantumNous/new-api/setting/config" +) + +type MonitorSetting struct { + AutoTestChannelEnabled bool `json:"auto_test_channel_enabled"` + AutoTestChannelMinutes float64 `json:"auto_test_channel_minutes"` +} + +// 默认配置 +var monitorSetting = MonitorSetting{ + AutoTestChannelEnabled: false, + AutoTestChannelMinutes: 10, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("monitor_setting", &monitorSetting) +} + +func GetMonitorSetting() *MonitorSetting { + if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" { + frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY")) + if err == nil && frequency > 0 { + monitorSetting.AutoTestChannelEnabled = true + monitorSetting.AutoTestChannelMinutes = float64(frequency) + } + } + return &monitorSetting +} diff --git a/setting/operation_setting/operation_setting.go b/setting/operation_setting/operation_setting.go new file mode 100644 index 0000000000000000000000000000000000000000..ef330d1adb876a6a2e0e00a1e1d1261d7e9b4faa --- /dev/null +++ b/setting/operation_setting/operation_setting.go @@ -0,0 +1,32 @@ +package operation_setting + +import "strings" + +var DemoSiteEnabled = false +var SelfUseModeEnabled = false + +var AutomaticDisableKeywords = []string{ + "Your credit balance is too low", + "This organization has been disabled.", + "You exceeded your current quota", + "Permission denied", + "The security token included in the request is invalid", + "Operation not allowed", + "Your account is not authorized", +} + +func AutomaticDisableKeywordsToString() string { + return strings.Join(AutomaticDisableKeywords, "\n") +} + +func AutomaticDisableKeywordsFromString(s string) { + AutomaticDisableKeywords = []string{} + ak := strings.Split(s, "\n") + for _, k := range ak { + k = strings.TrimSpace(k) + k = strings.ToLower(k) + if k != "" { + AutomaticDisableKeywords = append(AutomaticDisableKeywords, k) + } + } +} diff --git a/setting/operation_setting/payment_setting.go b/setting/operation_setting/payment_setting.go new file mode 100644 index 0000000000000000000000000000000000000000..84162f4e525a7e210411a7370d058c856058b8b8 --- /dev/null +++ b/setting/operation_setting/payment_setting.go @@ -0,0 +1,23 @@ +package operation_setting + +import "github.com/QuantumNous/new-api/setting/config" + +type PaymentSetting struct { + AmountOptions []int `json:"amount_options"` + AmountDiscount map[int]float64 `json:"amount_discount"` // 充值金额对应的折扣,例如 100 元 0.9 表示 100 元充值享受 9 折优惠 +} + +// 默认配置 +var paymentSetting = PaymentSetting{ + AmountOptions: []int{10, 20, 50, 100, 200, 500}, + AmountDiscount: map[int]float64{}, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("payment_setting", &paymentSetting) +} + +func GetPaymentSetting() *PaymentSetting { + return &paymentSetting +} diff --git a/setting/operation_setting/payment_setting_old.go b/setting/operation_setting/payment_setting_old.go new file mode 100644 index 0000000000000000000000000000000000000000..d34b6f0b83f57b72f29cf3cc1f59ca46f073ccf4 --- /dev/null +++ b/setting/operation_setting/payment_setting_old.go @@ -0,0 +1,59 @@ +/** +此文件为旧版支付设置文件,如需增加新的参数、变量等,请在 payment_setting.go 中添加 +This file is the old version of the payment settings file. If you need to add new parameters, variables, etc., please add them in payment_setting.go +*/ + +package operation_setting + +import ( + "github.com/QuantumNous/new-api/common" +) + +var PayAddress = "" +var CustomCallbackAddress = "" +var EpayId = "" +var EpayKey = "" +var Price = 7.3 +var MinTopUp = 1 +var USDExchangeRate = 7.3 + +var PayMethods = []map[string]string{ + { + "name": "支付宝", + "color": "rgba(var(--semi-blue-5), 1)", + "type": "alipay", + }, + { + "name": "微信", + "color": "rgba(var(--semi-green-5), 1)", + "type": "wxpay", + }, + { + "name": "自定义1", + "color": "black", + "type": "custom1", + "min_topup": "50", + }, +} + +func UpdatePayMethodsByJsonString(jsonString string) error { + PayMethods = make([]map[string]string, 0) + return common.Unmarshal([]byte(jsonString), &PayMethods) +} + +func PayMethods2JsonString() string { + jsonBytes, err := common.Marshal(PayMethods) + if err != nil { + return "[]" + } + return string(jsonBytes) +} + +func ContainsPayMethod(method string) bool { + for _, payMethod := range PayMethods { + if payMethod["type"] == method { + return true + } + } + return false +} diff --git a/setting/operation_setting/quota_setting.go b/setting/operation_setting/quota_setting.go new file mode 100644 index 0000000000000000000000000000000000000000..dcf0501a92db8585fea192ac155cfffffc2f78cd --- /dev/null +++ b/setting/operation_setting/quota_setting.go @@ -0,0 +1,21 @@ +package operation_setting + +import "github.com/QuantumNous/new-api/setting/config" + +type QuotaSetting struct { + EnableFreeModelPreConsume bool `json:"enable_free_model_pre_consume"` // 是否对免费模型启用预消耗 +} + +// 默认配置 +var quotaSetting = QuotaSetting{ + EnableFreeModelPreConsume: true, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("quota_setting", "aSetting) +} + +func GetQuotaSetting() *QuotaSetting { + return "aSetting +} diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go new file mode 100644 index 0000000000000000000000000000000000000000..adb76bfc03dbd4514c285d69d5b0880edc0c20de --- /dev/null +++ b/setting/operation_setting/tools.go @@ -0,0 +1,110 @@ +package operation_setting + +import "strings" + +const ( + // Web search + WebSearchPriceHigh = 25.00 + WebSearchPrice = 10.00 + // File search + FileSearchPrice = 2.5 +) + +const ( + GPTImage1Low1024x1024 = 0.011 + GPTImage1Low1024x1536 = 0.016 + GPTImage1Low1536x1024 = 0.016 + GPTImage1Medium1024x1024 = 0.042 + GPTImage1Medium1024x1536 = 0.063 + GPTImage1Medium1536x1024 = 0.063 + GPTImage1High1024x1024 = 0.167 + GPTImage1High1024x1536 = 0.25 + GPTImage1High1536x1024 = 0.25 +) + +const ( + // Gemini Audio Input Price + Gemini25FlashPreviewInputAudioPrice = 1.00 + Gemini25FlashProductionInputAudioPrice = 1.00 // for `gemini-2.5-flash` + Gemini25FlashLitePreviewInputAudioPrice = 0.50 + Gemini25FlashNativeAudioInputAudioPrice = 3.00 + Gemini20FlashInputAudioPrice = 0.70 + GeminiRoboticsER15InputAudioPrice = 1.00 +) + +const ( + // Claude Web search + ClaudeWebSearchPrice = 10.00 +) + +func GetClaudeWebSearchPricePerThousand() float64 { + return ClaudeWebSearchPrice +} + +func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 { + // 确定模型类型 + // https://platform.openai.com/docs/pricing Web search 价格按模型类型收费 + // 新版计费规则不再关联 search context size,故在const区域将各size的价格设为一致。 + // gpt-5, gpt-5-mini, gpt-5-nano 和 o 系列模型价格为 10.00 美元/千次调用,产生额外 token 计入 input_tokens + // gpt-4o, gpt-4.1, gpt-4o-mini 和 gpt-4.1-mini 价格为 25.00 美元/千次调用,不产生额外 token + isNormalPriceModel := + strings.HasPrefix(modelName, "o3") || + strings.HasPrefix(modelName, "o4") || + strings.HasPrefix(modelName, "gpt-5") + var priceWebSearchPerThousandCalls float64 + if isNormalPriceModel { + priceWebSearchPerThousandCalls = WebSearchPrice + } else { + priceWebSearchPerThousandCalls = WebSearchPriceHigh + } + return priceWebSearchPerThousandCalls +} + +func GetFileSearchPricePerThousand() float64 { + return FileSearchPrice +} + +func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 { + if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") { + return Gemini25FlashNativeAudioInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-lite") { + return Gemini25FlashLitePreviewInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") { + return Gemini25FlashPreviewInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.5-flash") { + return Gemini25FlashProductionInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.0-flash") { + return Gemini20FlashInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") { + return GeminiRoboticsER15InputAudioPrice + } + return 0 +} + +func GetGPTImage1PriceOnceCall(quality string, size string) float64 { + prices := map[string]map[string]float64{ + "low": { + "1024x1024": GPTImage1Low1024x1024, + "1024x1536": GPTImage1Low1024x1536, + "1536x1024": GPTImage1Low1536x1024, + }, + "medium": { + "1024x1024": GPTImage1Medium1024x1024, + "1024x1536": GPTImage1Medium1024x1536, + "1536x1024": GPTImage1Medium1536x1024, + }, + "high": { + "1024x1024": GPTImage1High1024x1024, + "1024x1536": GPTImage1High1024x1536, + "1536x1024": GPTImage1High1536x1024, + }, + } + + if qualityMap, exists := prices[quality]; exists { + if price, exists := qualityMap[size]; exists { + return price + } + } + + return GPTImage1High1024x1024 +} diff --git a/setting/payment_creem.go b/setting/payment_creem.go new file mode 100644 index 0000000000000000000000000000000000000000..0e6b7ee2b581763cd6be36f19c9233f81d71da79 --- /dev/null +++ b/setting/payment_creem.go @@ -0,0 +1,6 @@ +package setting + +var CreemApiKey = "" +var CreemProducts = "[]" +var CreemTestMode = false +var CreemWebhookSecret = "" diff --git a/setting/payment_stripe.go b/setting/payment_stripe.go new file mode 100644 index 0000000000000000000000000000000000000000..d97120c8523c6a2b329e53e22c204b8b39607964 --- /dev/null +++ b/setting/payment_stripe.go @@ -0,0 +1,8 @@ +package setting + +var StripeApiSecret = "" +var StripeWebhookSecret = "" +var StripePriceId = "" +var StripeUnitPrice = 8.0 +var StripeMinTopUp = 1 +var StripePromotionCodesEnabled = false diff --git a/setting/rate_limit.go b/setting/rate_limit.go new file mode 100644 index 0000000000000000000000000000000000000000..413f3958d759da6e9436d1abeb8a759a1d638741 --- /dev/null +++ b/setting/rate_limit.go @@ -0,0 +1,69 @@ +package setting + +import ( + "encoding/json" + "fmt" + "math" + "sync" + + "github.com/QuantumNous/new-api/common" +) + +var ModelRequestRateLimitEnabled = false +var ModelRequestRateLimitDurationMinutes = 1 +var ModelRequestRateLimitCount = 0 +var ModelRequestRateLimitSuccessCount = 1000 +var ModelRequestRateLimitGroup = map[string][2]int{} +var ModelRequestRateLimitMutex sync.RWMutex + +func ModelRequestRateLimitGroup2JSONString() string { + ModelRequestRateLimitMutex.RLock() + defer ModelRequestRateLimitMutex.RUnlock() + + jsonBytes, err := json.Marshal(ModelRequestRateLimitGroup) + if err != nil { + common.SysLog("error marshalling model ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateModelRequestRateLimitGroupByJSONString(jsonStr string) error { + ModelRequestRateLimitMutex.RLock() + defer ModelRequestRateLimitMutex.RUnlock() + + ModelRequestRateLimitGroup = make(map[string][2]int) + return json.Unmarshal([]byte(jsonStr), &ModelRequestRateLimitGroup) +} + +func GetGroupRateLimit(group string) (totalCount, successCount int, found bool) { + ModelRequestRateLimitMutex.RLock() + defer ModelRequestRateLimitMutex.RUnlock() + + if ModelRequestRateLimitGroup == nil { + return 0, 0, false + } + + limits, found := ModelRequestRateLimitGroup[group] + if !found { + return 0, 0, false + } + return limits[0], limits[1], true +} + +func CheckModelRequestRateLimitGroup(jsonStr string) error { + checkModelRequestRateLimitGroup := make(map[string][2]int) + err := json.Unmarshal([]byte(jsonStr), &checkModelRequestRateLimitGroup) + if err != nil { + return err + } + for group, limits := range checkModelRequestRateLimitGroup { + if limits[0] < 0 || limits[1] < 1 { + return fmt.Errorf("group %s has negative rate limit values: [%d, %d]", group, limits[0], limits[1]) + } + if limits[0] > math.MaxInt32 || limits[1] > math.MaxInt32 { + return fmt.Errorf("group %s [%d, %d] has max rate limits value 2147483647", group, limits[0], limits[1]) + } + } + + return nil +} diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go new file mode 100644 index 0000000000000000000000000000000000000000..3b317bc18d01ce5665de105eb98180b52a6fe80b --- /dev/null +++ b/setting/ratio_setting/cache_ratio.go @@ -0,0 +1,145 @@ +package ratio_setting + +import ( + "encoding/json" + "sync" + + "github.com/QuantumNous/new-api/common" +) + +var defaultCacheRatio = map[string]float64{ + "gpt-4": 0.5, + "o1": 0.5, + "o1-2024-12-17": 0.5, + "o1-preview-2024-09-12": 0.5, + "o1-preview": 0.5, + "o1-mini-2024-09-12": 0.5, + "o1-mini": 0.5, + "o3-mini": 0.5, + "o3-mini-2025-01-31": 0.5, + "gpt-4o-2024-11-20": 0.5, + "gpt-4o-2024-08-06": 0.5, + "gpt-4o": 0.5, + "gpt-4o-mini-2024-07-18": 0.5, + "gpt-4o-mini": 0.5, + "gpt-4o-realtime-preview": 0.5, + "gpt-4o-mini-realtime-preview": 0.5, + "gpt-4.5-preview": 0.5, + "gpt-4.5-preview-2025-02-27": 0.5, + "gpt-4.1": 0.25, + "gpt-4.1-mini": 0.25, + "gpt-4.1-nano": 0.25, + "gpt-5": 0.1, + "gpt-5-2025-08-07": 0.1, + "gpt-5-chat-latest": 0.1, + "gpt-5-mini": 0.1, + "gpt-5-mini-2025-08-07": 0.1, + "gpt-5-nano": 0.1, + "gpt-5-nano-2025-08-07": 0.1, + "deepseek-chat": 0.25, + "deepseek-reasoner": 0.25, + "deepseek-coder": 0.25, + "claude-3-sonnet-20240229": 0.1, + "claude-3-opus-20240229": 0.1, + "claude-3-haiku-20240307": 0.1, + "claude-3-5-haiku-20241022": 0.1, + "claude-3-5-sonnet-20240620": 0.1, + "claude-3-5-sonnet-20241022": 0.1, + "claude-3-7-sonnet-20250219": 0.1, + "claude-3-7-sonnet-20250219-thinking": 0.1, + "claude-sonnet-4-20250514": 0.1, + "claude-sonnet-4-20250514-thinking": 0.1, + "claude-opus-4-20250514": 0.1, + "claude-opus-4-20250514-thinking": 0.1, + "claude-opus-4-1-20250805": 0.1, + "claude-opus-4-1-20250805-thinking": 0.1, + "claude-sonnet-4-5-20250929": 0.1, + "claude-sonnet-4-5-20250929-thinking": 0.1, + "claude-opus-4-5-20251101": 0.1, + "claude-opus-4-5-20251101-thinking": 0.1, +} + +var defaultCreateCacheRatio = map[string]float64{ + "claude-3-sonnet-20240229": 1.25, + "claude-3-opus-20240229": 1.25, + "claude-3-haiku-20240307": 1.25, + "claude-3-5-haiku-20241022": 1.25, + "claude-3-5-sonnet-20240620": 1.25, + "claude-3-5-sonnet-20241022": 1.25, + "claude-3-7-sonnet-20250219": 1.25, + "claude-3-7-sonnet-20250219-thinking": 1.25, + "claude-sonnet-4-20250514": 1.25, + "claude-sonnet-4-20250514-thinking": 1.25, + "claude-opus-4-20250514": 1.25, + "claude-opus-4-20250514-thinking": 1.25, + "claude-opus-4-1-20250805": 1.25, + "claude-opus-4-1-20250805-thinking": 1.25, + "claude-sonnet-4-5-20250929": 1.25, + "claude-sonnet-4-5-20250929-thinking": 1.25, + "claude-opus-4-5-20251101": 1.25, + "claude-opus-4-5-20251101-thinking": 1.25, +} + +//var defaultCreateCacheRatio = map[string]float64{} + +var cacheRatioMap map[string]float64 +var cacheRatioMapMutex sync.RWMutex + +// GetCacheRatioMap returns the cache ratio map +func GetCacheRatioMap() map[string]float64 { + cacheRatioMapMutex.RLock() + defer cacheRatioMapMutex.RUnlock() + return cacheRatioMap +} + +// CacheRatio2JSONString converts the cache ratio map to a JSON string +func CacheRatio2JSONString() string { + cacheRatioMapMutex.RLock() + defer cacheRatioMapMutex.RUnlock() + jsonBytes, err := json.Marshal(cacheRatioMap) + if err != nil { + common.SysLog("error marshalling cache ratio: " + err.Error()) + } + return string(jsonBytes) +} + +// UpdateCacheRatioByJSONString updates the cache ratio map from a JSON string +func UpdateCacheRatioByJSONString(jsonStr string) error { + cacheRatioMapMutex.Lock() + defer cacheRatioMapMutex.Unlock() + cacheRatioMap = make(map[string]float64) + err := json.Unmarshal([]byte(jsonStr), &cacheRatioMap) + if err == nil { + InvalidateExposedDataCache() + } + return err +} + +// GetCacheRatio returns the cache ratio for a model +func GetCacheRatio(name string) (float64, bool) { + cacheRatioMapMutex.RLock() + defer cacheRatioMapMutex.RUnlock() + ratio, ok := cacheRatioMap[name] + if !ok { + return 1, false // Default to 1 if not found + } + return ratio, true +} + +func GetCreateCacheRatio(name string) (float64, bool) { + ratio, ok := defaultCreateCacheRatio[name] + if !ok { + return 1.25, false // Default to 1.25 if not found + } + return ratio, true +} + +func GetCacheRatioCopy() map[string]float64 { + cacheRatioMapMutex.RLock() + defer cacheRatioMapMutex.RUnlock() + copyMap := make(map[string]float64, len(cacheRatioMap)) + for k, v := range cacheRatioMap { + copyMap[k] = v + } + return copyMap +} diff --git a/setting/ratio_setting/expose_ratio.go b/setting/ratio_setting/expose_ratio.go new file mode 100644 index 0000000000000000000000000000000000000000..783d9778ebbd2b83a2c15ca463e1ff17ef3005a1 --- /dev/null +++ b/setting/ratio_setting/expose_ratio.go @@ -0,0 +1,17 @@ +package ratio_setting + +import "sync/atomic" + +var exposeRatioEnabled atomic.Bool + +func init() { + exposeRatioEnabled.Store(false) +} + +func SetExposeRatioEnabled(enabled bool) { + exposeRatioEnabled.Store(enabled) +} + +func IsExposeRatioEnabled() bool { + return exposeRatioEnabled.Load() +} diff --git a/setting/ratio_setting/exposed_cache.go b/setting/ratio_setting/exposed_cache.go new file mode 100644 index 0000000000000000000000000000000000000000..2fe2cd09b49f059708724d82df7021ccc916a08a --- /dev/null +++ b/setting/ratio_setting/exposed_cache.go @@ -0,0 +1,55 @@ +package ratio_setting + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" +) + +const exposedDataTTL = 30 * time.Second + +type exposedCache struct { + data gin.H + expiresAt time.Time +} + +var ( + exposedData atomic.Value + rebuildMu sync.Mutex +) + +func InvalidateExposedDataCache() { + exposedData.Store((*exposedCache)(nil)) +} + +func cloneGinH(src gin.H) gin.H { + dst := make(gin.H, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func GetExposedData() gin.H { + if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { + return cloneGinH(c.data) + } + rebuildMu.Lock() + defer rebuildMu.Unlock() + if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { + return cloneGinH(c.data) + } + newData := gin.H{ + "model_ratio": GetModelRatioCopy(), + "completion_ratio": GetCompletionRatioCopy(), + "cache_ratio": GetCacheRatioCopy(), + "model_price": GetModelPriceCopy(), + } + exposedData.Store(&exposedCache{ + data: newData, + expiresAt: time.Now().Add(exposedDataTTL), + }) + return cloneGinH(newData) +} diff --git a/setting/ratio_setting/group_ratio.go b/setting/ratio_setting/group_ratio.go new file mode 100644 index 0000000000000000000000000000000000000000..9bb26557b9846b2362639d94de8db2f140efa0e8 --- /dev/null +++ b/setting/ratio_setting/group_ratio.go @@ -0,0 +1,162 @@ +package ratio_setting + +import ( + "encoding/json" + "errors" + "sync" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/config" + "github.com/QuantumNous/new-api/types" +) + +var groupRatio = map[string]float64{ + "default": 1, + "vip": 1, + "svip": 1, +} + +var groupRatioMutex sync.RWMutex + +var ( + GroupGroupRatio = map[string]map[string]float64{ + "vip": { + "edit_this": 0.9, + }, + } + groupGroupRatioMutex sync.RWMutex +) + +var defaultGroupSpecialUsableGroup = map[string]map[string]string{ + "vip": { + "append_1": "vip_special_group_1", + "-:remove_1": "vip_removed_group_1", + }, +} + +type GroupRatioSetting struct { + GroupRatio map[string]float64 `json:"group_ratio"` + GroupGroupRatio map[string]map[string]float64 `json:"group_group_ratio"` + GroupSpecialUsableGroup *types.RWMap[string, map[string]string] `json:"group_special_usable_group"` +} + +var groupRatioSetting GroupRatioSetting + +func init() { + groupSpecialUsableGroup := types.NewRWMap[string, map[string]string]() + groupSpecialUsableGroup.AddAll(defaultGroupSpecialUsableGroup) + + groupRatioSetting = GroupRatioSetting{ + GroupSpecialUsableGroup: groupSpecialUsableGroup, + GroupRatio: groupRatio, + GroupGroupRatio: GroupGroupRatio, + } + + config.GlobalConfig.Register("group_ratio_setting", &groupRatioSetting) +} + +func GetGroupRatioSetting() *GroupRatioSetting { + if groupRatioSetting.GroupSpecialUsableGroup == nil { + groupRatioSetting.GroupSpecialUsableGroup = types.NewRWMap[string, map[string]string]() + groupRatioSetting.GroupSpecialUsableGroup.AddAll(defaultGroupSpecialUsableGroup) + } + return &groupRatioSetting +} + +func GetGroupRatioCopy() map[string]float64 { + groupRatioMutex.RLock() + defer groupRatioMutex.RUnlock() + + groupRatioCopy := make(map[string]float64) + for k, v := range groupRatio { + groupRatioCopy[k] = v + } + return groupRatioCopy +} + +func ContainsGroupRatio(name string) bool { + groupRatioMutex.RLock() + defer groupRatioMutex.RUnlock() + + _, ok := groupRatio[name] + return ok +} + +func GroupRatio2JSONString() string { + groupRatioMutex.RLock() + defer groupRatioMutex.RUnlock() + + jsonBytes, err := json.Marshal(groupRatio) + if err != nil { + common.SysLog("error marshalling model ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateGroupRatioByJSONString(jsonStr string) error { + groupRatioMutex.Lock() + defer groupRatioMutex.Unlock() + + groupRatio = make(map[string]float64) + return json.Unmarshal([]byte(jsonStr), &groupRatio) +} + +func GetGroupRatio(name string) float64 { + groupRatioMutex.RLock() + defer groupRatioMutex.RUnlock() + + ratio, ok := groupRatio[name] + if !ok { + common.SysLog("group ratio not found: " + name) + return 1 + } + return ratio +} + +func GetGroupGroupRatio(userGroup, usingGroup string) (float64, bool) { + groupGroupRatioMutex.RLock() + defer groupGroupRatioMutex.RUnlock() + + gp, ok := GroupGroupRatio[userGroup] + if !ok { + return -1, false + } + ratio, ok := gp[usingGroup] + if !ok { + return -1, false + } + return ratio, true +} + +func GroupGroupRatio2JSONString() string { + groupGroupRatioMutex.RLock() + defer groupGroupRatioMutex.RUnlock() + + jsonBytes, err := json.Marshal(GroupGroupRatio) + if err != nil { + common.SysLog("error marshalling group-group ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateGroupGroupRatioByJSONString(jsonStr string) error { + groupGroupRatioMutex.Lock() + defer groupGroupRatioMutex.Unlock() + + GroupGroupRatio = make(map[string]map[string]float64) + return json.Unmarshal([]byte(jsonStr), &GroupGroupRatio) +} + +func CheckGroupRatio(jsonStr string) error { + checkGroupRatio := make(map[string]float64) + err := json.Unmarshal([]byte(jsonStr), &checkGroupRatio) + if err != nil { + return err + } + for name, ratio := range checkGroupRatio { + if ratio < 0 { + return errors.New("group ratio must be not less than 0: " + name) + } + } + return nil +} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go new file mode 100644 index 0000000000000000000000000000000000000000..2354f3ec88d8c7ded4d798940d158c1a06899f42 --- /dev/null +++ b/setting/ratio_setting/model_ratio.go @@ -0,0 +1,844 @@ +package ratio_setting + +import ( + "encoding/json" + "strings" + "sync" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/operation_setting" +) + +// from songquanpeng/one-api +const ( + USD2RMB = 7.3 // 暂定 1 USD = 7.3 RMB + USD = 500 // $0.002 = 1 -> $1 = 500 + RMB = USD / USD2RMB +) + +// modelRatio +// https://platform.openai.com/docs/models/model-endpoint-compatibility +// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Blfmc9dlf +// https://openai.com/pricing +// TODO: when a new api is enabled, check the pricing here +// 1 === $0.002 / 1K tokens +// 1 === ¥0.014 / 1k tokens + +var defaultModelRatio = map[string]float64{ + //"midjourney": 50, + "gpt-4-gizmo-*": 15, + "gpt-4o-gizmo-*": 2.5, + "gpt-4-all": 15, + "gpt-4o-all": 15, + "gpt-4": 15, + //"gpt-4-0314": 15, //deprecated + "gpt-4-0613": 15, + "gpt-4-32k": 30, + //"gpt-4-32k-0314": 30, //deprecated + "gpt-4-32k-0613": 30, + "gpt-4-1106-preview": 5, // $10 / 1M tokens + "gpt-4-0125-preview": 5, // $10 / 1M tokens + "gpt-4-turbo-preview": 5, // $10 / 1M tokens + "gpt-4-vision-preview": 5, // $10 / 1M tokens + "gpt-4-1106-vision-preview": 5, // $10 / 1M tokens + "chatgpt-4o-latest": 2.5, // $5 / 1M tokens + "gpt-4o": 1.25, // $2.5 / 1M tokens + "gpt-4o-audio-preview": 1.25, // $2.5 / 1M tokens + "gpt-4o-audio-preview-2024-10-01": 1.25, // $2.5 / 1M tokens + "gpt-4o-2024-05-13": 2.5, // $5 / 1M tokens + "gpt-4o-2024-08-06": 1.25, // $2.5 / 1M tokens + "gpt-4o-2024-11-20": 1.25, // $2.5 / 1M tokens + "gpt-4o-realtime-preview": 2.5, + "gpt-4o-realtime-preview-2024-10-01": 2.5, + "gpt-4o-realtime-preview-2024-12-17": 2.5, + "gpt-4o-mini-realtime-preview": 0.3, + "gpt-4o-mini-realtime-preview-2024-12-17": 0.3, + "gpt-4.1": 1.0, // $2 / 1M tokens + "gpt-4.1-2025-04-14": 1.0, // $2 / 1M tokens + "gpt-4.1-mini": 0.2, // $0.4 / 1M tokens + "gpt-4.1-mini-2025-04-14": 0.2, // $0.4 / 1M tokens + "gpt-4.1-nano": 0.05, // $0.1 / 1M tokens + "gpt-4.1-nano-2025-04-14": 0.05, // $0.1 / 1M tokens + "gpt-image-1": 2.5, // $5 / 1M tokens + "o1": 7.5, // $15 / 1M tokens + "o1-2024-12-17": 7.5, // $15 / 1M tokens + "o1-preview": 7.5, // $15 / 1M tokens + "o1-preview-2024-09-12": 7.5, // $15 / 1M tokens + "o1-mini": 0.55, // $1.1 / 1M tokens + "o1-mini-2024-09-12": 0.55, // $1.1 / 1M tokens + "o1-pro": 75.0, // $150 / 1M tokens + "o1-pro-2025-03-19": 75.0, // $150 / 1M tokens + "o3-mini": 0.55, + "o3-mini-2025-01-31": 0.55, + "o3-mini-high": 0.55, + "o3-mini-2025-01-31-high": 0.55, + "o3-mini-low": 0.55, + "o3-mini-2025-01-31-low": 0.55, + "o3-mini-medium": 0.55, + "o3-mini-2025-01-31-medium": 0.55, + "o3": 1.0, // $2 / 1M tokens + "o3-2025-04-16": 1.0, // $2 / 1M tokens + "o3-pro": 10.0, // $20 / 1M tokens + "o3-pro-2025-06-10": 10.0, // $20 / 1M tokens + "o3-deep-research": 5.0, // $10 / 1M tokens + "o3-deep-research-2025-06-26": 5.0, // $10 / 1M tokens + "o4-mini": 0.55, // $1.1 / 1M tokens + "o4-mini-2025-04-16": 0.55, // $1.1 / 1M tokens + "o4-mini-deep-research": 1.0, // $2 / 1M tokens + "o4-mini-deep-research-2025-06-26": 1.0, // $2 / 1M tokens + "gpt-4o-mini": 0.075, + "gpt-4o-mini-2024-07-18": 0.075, + "gpt-4-turbo": 5, // $0.01 / 1K tokens + "gpt-4-turbo-2024-04-09": 5, // $0.01 / 1K tokens + "gpt-4.5-preview": 37.5, + "gpt-4.5-preview-2025-02-27": 37.5, + "gpt-5": 0.625, + "gpt-5-2025-08-07": 0.625, + "gpt-5-chat-latest": 0.625, + "gpt-5-mini": 0.125, + "gpt-5-mini-2025-08-07": 0.125, + "gpt-5-nano": 0.025, + "gpt-5-nano-2025-08-07": 0.025, + //"gpt-3.5-turbo-0301": 0.75, //deprecated + "gpt-3.5-turbo": 0.25, + "gpt-3.5-turbo-0613": 0.75, + "gpt-3.5-turbo-16k": 1.5, // $0.003 / 1K tokens + "gpt-3.5-turbo-16k-0613": 1.5, + "gpt-3.5-turbo-instruct": 0.75, // $0.0015 / 1K tokens + "gpt-3.5-turbo-1106": 0.5, // $0.001 / 1K tokens + "gpt-3.5-turbo-0125": 0.25, + "babbage-002": 0.2, // $0.0004 / 1K tokens + "davinci-002": 1, // $0.002 / 1K tokens + "text-ada-001": 0.2, + "text-babbage-001": 0.25, + "text-curie-001": 1, + //"text-davinci-002": 10, + //"text-davinci-003": 10, + "text-davinci-edit-001": 10, + "code-davinci-edit-001": 10, + "whisper-1": 15, // $0.006 / minute -> $0.006 / 150 words -> $0.006 / 200 tokens -> $0.03 / 1k tokens + "tts-1": 7.5, // 1k characters -> $0.015 + "tts-1-1106": 7.5, // 1k characters -> $0.015 + "tts-1-hd": 15, // 1k characters -> $0.03 + "tts-1-hd-1106": 15, // 1k characters -> $0.03 + "davinci": 10, + "curie": 10, + "babbage": 10, + "ada": 10, + "text-embedding-3-small": 0.01, + "text-embedding-3-large": 0.065, + "text-embedding-ada-002": 0.05, + "text-search-ada-doc-001": 10, + "text-moderation-stable": 0.1, + "text-moderation-latest": 0.1, + "claude-instant-1": 0.4, // $0.8 / 1M tokens + "claude-2.0": 4, // $8 / 1M tokens + "claude-2.1": 4, // $8 / 1M tokens + "claude-3-haiku-20240307": 0.125, // $0.25 / 1M tokens + "claude-3-5-haiku-20241022": 0.5, // $1 / 1M tokens + "claude-3-sonnet-20240229": 1.5, // $3 / 1M tokens + "claude-3-5-sonnet-20240620": 1.5, + "claude-3-5-sonnet-20241022": 1.5, + "claude-3-7-sonnet-20250219": 1.5, + "claude-3-7-sonnet-20250219-thinking": 1.5, + "claude-sonnet-4-20250514": 1.5, + "claude-sonnet-4-5-20250929": 1.5, + "claude-opus-4-5-20251101": 2.5, + "claude-3-opus-20240229": 7.5, // $15 / 1M tokens + "claude-opus-4-20250514": 7.5, + "claude-opus-4-1-20250805": 7.5, + "ERNIE-4.0-8K": 0.120 * RMB, + "ERNIE-3.5-8K": 0.012 * RMB, + "ERNIE-3.5-8K-0205": 0.024 * RMB, + "ERNIE-3.5-8K-1222": 0.012 * RMB, + "ERNIE-Bot-8K": 0.024 * RMB, + "ERNIE-3.5-4K-0205": 0.012 * RMB, + "ERNIE-Speed-8K": 0.004 * RMB, + "ERNIE-Speed-128K": 0.004 * RMB, + "ERNIE-Lite-8K-0922": 0.008 * RMB, + "ERNIE-Lite-8K-0308": 0.003 * RMB, + "ERNIE-Tiny-8K": 0.001 * RMB, + "BLOOMZ-7B": 0.004 * RMB, + "Embedding-V1": 0.002 * RMB, + "bge-large-zh": 0.002 * RMB, + "bge-large-en": 0.002 * RMB, + "tao-8k": 0.002 * RMB, + "PaLM-2": 1, + "gemini-1.5-pro-latest": 1.25, // $3.5 / 1M tokens + "gemini-1.5-flash-latest": 0.075, + "gemini-2.0-flash": 0.05, + "gemini-2.5-pro-exp-03-25": 0.625, + "gemini-2.5-pro-preview-03-25": 0.625, + "gemini-2.5-pro": 0.625, + "gemini-2.5-flash-preview-04-17": 0.075, + "gemini-2.5-flash-preview-04-17-thinking": 0.075, + "gemini-2.5-flash-preview-04-17-nothinking": 0.075, + "gemini-2.5-flash-preview-05-20": 0.075, + "gemini-2.5-flash-preview-05-20-thinking": 0.075, + "gemini-2.5-flash-preview-05-20-nothinking": 0.075, + "gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率 + "gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率 + "gemini-2.5-flash-lite-preview-thinking-*": 0.05, + "gemini-2.5-flash-lite-preview-06-17": 0.05, + "gemini-2.5-flash": 0.15, + "gemini-robotics-er-1.5-preview": 0.15, + "gemini-embedding-001": 0.075, + "text-embedding-004": 0.001, + "chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens + "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens + "chatglm_std": 0.3572, // ¥0.005 / 1k tokens + "chatglm_lite": 0.1429, // ¥0.002 / 1k tokens + "glm-4": 7.143, // ¥0.1 / 1k tokens + "glm-4v": 0.05 * RMB, // ¥0.05 / 1k tokens + "glm-4-alltools": 0.1 * RMB, // ¥0.1 / 1k tokens + "glm-3-turbo": 0.3572, + "glm-4-plus": 0.05 * RMB, + "glm-4-0520": 0.1 * RMB, + "glm-4-air": 0.001 * RMB, + "glm-4-airx": 0.01 * RMB, + "glm-4-long": 0.001 * RMB, + "glm-4-flash": 0, + "glm-4v-plus": 0.01 * RMB, + "qwen-turbo": 0.8572, // ¥0.012 / 1k tokens + "qwen-plus": 10, // ¥0.14 / 1k tokens + "text-embedding-v1": 0.05, // ¥0.0007 / 1k tokens + "SparkDesk-v1.1": 1.2858, // ¥0.018 / 1k tokens + "SparkDesk-v2.1": 1.2858, // ¥0.018 / 1k tokens + "SparkDesk-v3.1": 1.2858, // ¥0.018 / 1k tokens + "SparkDesk-v3.5": 1.2858, // ¥0.018 / 1k tokens + "SparkDesk-v4.0": 1.2858, + "360GPT_S2_V9": 0.8572, // ¥0.012 / 1k tokens + "360gpt-turbo": 0.0858, // ¥0.0012 / 1k tokens + "360gpt-turbo-responsibility-8k": 0.8572, // ¥0.012 / 1k tokens + "360gpt-pro": 0.8572, // ¥0.012 / 1k tokens + "360gpt2-pro": 0.8572, // ¥0.012 / 1k tokens + "embedding-bert-512-v1": 0.0715, // ¥0.001 / 1k tokens + "embedding_s1_v1": 0.0715, // ¥0.001 / 1k tokens + "semantic_similarity_s1_v1": 0.0715, // ¥0.001 / 1k tokens + "hunyuan": 7.143, // ¥0.1 / 1k tokens // https://cloud.tencent.com/document/product/1729/97731#e0e6be58-60c8-469f-bdeb-6c264ce3b4d0 + // https://platform.lingyiwanwu.com/docs#-计费单元 + // 已经按照 7.2 来换算美元价格 + "yi-34b-chat-0205": 0.18, + "yi-34b-chat-200k": 0.864, + "yi-vl-plus": 0.432, + "yi-large": 20.0 / 1000 * RMB, + "yi-medium": 2.5 / 1000 * RMB, + "yi-vision": 6.0 / 1000 * RMB, + "yi-medium-200k": 12.0 / 1000 * RMB, + "yi-spark": 1.0 / 1000 * RMB, + "yi-large-rag": 25.0 / 1000 * RMB, + "yi-large-turbo": 12.0 / 1000 * RMB, + "yi-large-preview": 20.0 / 1000 * RMB, + "yi-large-rag-preview": 25.0 / 1000 * RMB, + "command": 0.5, + "command-nightly": 0.5, + "command-light": 0.5, + "command-light-nightly": 0.5, + "command-r": 0.25, + "command-r-plus": 1.5, + "command-r-08-2024": 0.075, + "command-r-plus-08-2024": 1.25, + "deepseek-chat": 0.27 / 2, + "deepseek-coder": 0.27 / 2, + "deepseek-reasoner": 0.55 / 2, // 0.55 / 1k tokens + // Perplexity online 模型对搜索额外收费,有需要应自行调整,此处不计入搜索费用 + "llama-3-sonar-small-32k-chat": 0.2 / 1000 * USD, + "llama-3-sonar-small-32k-online": 0.2 / 1000 * USD, + "llama-3-sonar-large-32k-chat": 1 / 1000 * USD, + "llama-3-sonar-large-32k-online": 1 / 1000 * USD, + // grok + "grok-3-beta": 1.5, + "grok-3-mini-beta": 0.15, + "grok-2": 1, + "grok-2-vision": 1, + "grok-beta": 2.5, + "grok-vision-beta": 2.5, + "grok-3-fast-beta": 2.5, + "grok-3-mini-fast-beta": 0.3, + // submodel + "NousResearch/Hermes-4-405B-FP8": 0.8, + "Qwen/Qwen3-235B-A22B-Thinking-2507": 0.6, + "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": 0.8, + "Qwen/Qwen3-235B-A22B-Instruct-2507": 0.3, + "zai-org/GLM-4.5-FP8": 0.8, + "openai/gpt-oss-120b": 0.5, + "deepseek-ai/DeepSeek-R1-0528": 0.8, + "deepseek-ai/DeepSeek-R1": 0.8, + "deepseek-ai/DeepSeek-V3-0324": 0.8, + "deepseek-ai/DeepSeek-V3.1": 0.8, +} + +var defaultModelPrice = map[string]float64{ + "suno_music": 0.1, + "suno_lyrics": 0.01, + "dall-e-3": 0.04, + "imagen-3.0-generate-002": 0.03, + "black-forest-labs/flux-1.1-pro": 0.04, + "gpt-4-gizmo-*": 0.1, + "mj_video": 0.8, + "mj_imagine": 0.1, + "mj_edits": 0.1, + "mj_variation": 0.1, + "mj_reroll": 0.1, + "mj_blend": 0.1, + "mj_modal": 0.1, + "mj_zoom": 0.1, + "mj_shorten": 0.1, + "mj_high_variation": 0.1, + "mj_low_variation": 0.1, + "mj_pan": 0.1, + "mj_inpaint": 0, + "mj_custom_zoom": 0, + "mj_describe": 0.05, + "mj_upscale": 0.05, + "swap_face": 0.05, + "mj_upload": 0.05, + "sora-2": 0.3, + "sora-2-pro": 0.5, +} + +var defaultAudioRatio = map[string]float64{ + "gpt-4o-audio-preview": 16, + "gpt-4o-mini-audio-preview": 66.67, + "gpt-4o-realtime-preview": 8, + "gpt-4o-mini-realtime-preview": 16.67, +} + +var defaultAudioCompletionRatio = map[string]float64{ + "gpt-4o-realtime": 2, + "gpt-4o-mini-realtime": 2, +} + +var ( + modelPriceMap map[string]float64 = nil + modelPriceMapMutex = sync.RWMutex{} +) +var ( + modelRatioMap map[string]float64 = nil + modelRatioMapMutex = sync.RWMutex{} +) + +var ( + CompletionRatio map[string]float64 = nil + CompletionRatioMutex = sync.RWMutex{} +) + +var defaultCompletionRatio = map[string]float64{ + "gpt-4-gizmo-*": 2, + "gpt-4o-gizmo-*": 3, + "gpt-4-all": 2, + "gpt-image-1": 8, +} + +// InitRatioSettings initializes all model related settings maps +func InitRatioSettings() { + // Initialize modelPriceMap + modelPriceMapMutex.Lock() + modelPriceMap = defaultModelPrice + modelPriceMapMutex.Unlock() + + // Initialize modelRatioMap + modelRatioMapMutex.Lock() + modelRatioMap = defaultModelRatio + modelRatioMapMutex.Unlock() + + // Initialize CompletionRatio + CompletionRatioMutex.Lock() + CompletionRatio = defaultCompletionRatio + CompletionRatioMutex.Unlock() + + // Initialize cacheRatioMap + cacheRatioMapMutex.Lock() + cacheRatioMap = defaultCacheRatio + cacheRatioMapMutex.Unlock() + + // initialize imageRatioMap + imageRatioMapMutex.Lock() + imageRatioMap = defaultImageRatio + imageRatioMapMutex.Unlock() + + // initialize audioRatioMap + audioRatioMapMutex.Lock() + audioRatioMap = defaultAudioRatio + audioRatioMapMutex.Unlock() + + // initialize audioCompletionRatioMap + audioCompletionRatioMapMutex.Lock() + audioCompletionRatioMap = defaultAudioCompletionRatio + audioCompletionRatioMapMutex.Unlock() +} + +func GetModelPriceMap() map[string]float64 { + modelPriceMapMutex.RLock() + defer modelPriceMapMutex.RUnlock() + return modelPriceMap +} + +func ModelPrice2JSONString() string { + modelPriceMapMutex.RLock() + defer modelPriceMapMutex.RUnlock() + + jsonBytes, err := common.Marshal(modelPriceMap) + if err != nil { + common.SysError("error marshalling model price: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateModelPriceByJSONString(jsonStr string) error { + modelPriceMapMutex.Lock() + defer modelPriceMapMutex.Unlock() + modelPriceMap = make(map[string]float64) + err := json.Unmarshal([]byte(jsonStr), &modelPriceMap) + if err == nil { + InvalidateExposedDataCache() + } + return err +} + +// GetModelPrice 返回模型的价格,如果模型不存在则返回-1,false +func GetModelPrice(name string, printErr bool) (float64, bool) { + modelPriceMapMutex.RLock() + defer modelPriceMapMutex.RUnlock() + + name = FormatMatchingModelName(name) + + price, ok := modelPriceMap[name] + if !ok { + if printErr { + common.SysError("model price not found: " + name) + } + return -1, false + } + return price, true +} + +func UpdateModelRatioByJSONString(jsonStr string) error { + modelRatioMapMutex.Lock() + defer modelRatioMapMutex.Unlock() + modelRatioMap = make(map[string]float64) + err := common.Unmarshal([]byte(jsonStr), &modelRatioMap) + if err == nil { + InvalidateExposedDataCache() + } + return err +} + +// 处理带有思考预算的模型名称,方便统一定价 +func handleThinkingBudgetModel(name, prefix, wildcard string) string { + if strings.HasPrefix(name, prefix) && strings.Contains(name, "-thinking-") { + return wildcard + } + return name +} + +func GetModelRatio(name string) (float64, bool, string) { + modelRatioMapMutex.RLock() + defer modelRatioMapMutex.RUnlock() + + name = FormatMatchingModelName(name) + + ratio, ok := modelRatioMap[name] + if !ok { + return 37.5, operation_setting.SelfUseModeEnabled, name + } + return ratio, true, name +} + +func DefaultModelRatio2JSONString() string { + jsonBytes, err := common.Marshal(defaultModelRatio) + if err != nil { + common.SysError("error marshalling model ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func GetDefaultModelRatioMap() map[string]float64 { + return defaultModelRatio +} + +func GetDefaultModelPriceMap() map[string]float64 { + return defaultModelPrice +} + +func GetDefaultImageRatioMap() map[string]float64 { + return defaultImageRatio +} + +func GetDefaultAudioRatioMap() map[string]float64 { + return defaultAudioRatio +} + +func GetDefaultAudioCompletionRatioMap() map[string]float64 { + return defaultAudioCompletionRatio +} + +func GetCompletionRatioMap() map[string]float64 { + CompletionRatioMutex.RLock() + defer CompletionRatioMutex.RUnlock() + return CompletionRatio +} + +func CompletionRatio2JSONString() string { + CompletionRatioMutex.RLock() + defer CompletionRatioMutex.RUnlock() + + jsonBytes, err := json.Marshal(CompletionRatio) + if err != nil { + common.SysError("error marshalling completion ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateCompletionRatioByJSONString(jsonStr string) error { + CompletionRatioMutex.Lock() + defer CompletionRatioMutex.Unlock() + CompletionRatio = make(map[string]float64) + err := common.Unmarshal([]byte(jsonStr), &CompletionRatio) + if err == nil { + InvalidateExposedDataCache() + } + return err +} + +func GetCompletionRatio(name string) float64 { + CompletionRatioMutex.RLock() + defer CompletionRatioMutex.RUnlock() + + name = FormatMatchingModelName(name) + + if strings.Contains(name, "/") { + if ratio, ok := CompletionRatio[name]; ok { + return ratio + } + } + hardCodedRatio, contain := getHardcodedCompletionModelRatio(name) + if contain { + return hardCodedRatio + } + if ratio, ok := CompletionRatio[name]; ok { + return ratio + } + return hardCodedRatio +} + +func getHardcodedCompletionModelRatio(name string) (float64, bool) { + + isReservedModel := strings.HasSuffix(name, "-all") || strings.HasSuffix(name, "-gizmo-*") + if isReservedModel { + return 2, false + } + + if strings.HasPrefix(name, "gpt-") { + if strings.HasPrefix(name, "gpt-4o") { + if name == "gpt-4o-2024-05-13" { + return 3, true + } + return 4, true + } + // gpt-5 匹配 + if strings.HasPrefix(name, "gpt-5") { + return 8, true + } + // gpt-4.5-preview匹配 + if strings.HasPrefix(name, "gpt-4.5-preview") { + return 2, true + } + if strings.HasPrefix(name, "gpt-4-turbo") || strings.HasSuffix(name, "gpt-4-1106") || strings.HasSuffix(name, "gpt-4-1105") { + return 3, true + } + // 没有特殊标记的 gpt-4 模型默认倍率为 2 + return 2, false + } + if strings.HasPrefix(name, "o1") || strings.HasPrefix(name, "o3") { + return 4, true + } + if name == "chatgpt-4o-latest" { + return 3, true + } + + if strings.Contains(name, "claude-3") { + return 5, true + } else if strings.Contains(name, "claude-sonnet-4") || strings.Contains(name, "claude-opus-4") { + return 5, true + } else if strings.Contains(name, "claude-instant-1") || strings.Contains(name, "claude-2") { + return 3, true + } + + if strings.HasPrefix(name, "gpt-3.5") { + if name == "gpt-3.5-turbo" || strings.HasSuffix(name, "0125") { + // https://openai.com/blog/new-embedding-models-and-api-updates + // Updated GPT-3.5 Turbo model and lower pricing + return 3, true + } + if strings.HasSuffix(name, "1106") { + return 2, true + } + return 4.0 / 3.0, true + } + if strings.HasPrefix(name, "mistral-") { + return 3, true + } + if strings.HasPrefix(name, "gemini-") { + if strings.HasPrefix(name, "gemini-1.5") { + return 4, true + } else if strings.HasPrefix(name, "gemini-2.0") { + return 4, true + } else if strings.HasPrefix(name, "gemini-2.5-pro") { // 移除preview来增加兼容性,这里假设正式版的倍率和preview一致 + return 8, false + } else if strings.HasPrefix(name, "gemini-2.5-flash") { // 处理不同的flash模型倍率 + if strings.HasPrefix(name, "gemini-2.5-flash-preview") { + if strings.HasSuffix(name, "-nothinking") { + return 4, false + } + return 3.5 / 0.15, false + } + if strings.HasPrefix(name, "gemini-2.5-flash-lite") { + return 4, false + } + return 2.5 / 0.3, false + } else if strings.HasPrefix(name, "gemini-robotics-er-1.5") { + return 2.5 / 0.3, false + } else if strings.HasPrefix(name, "gemini-3-pro") { + if strings.HasPrefix(name, "gemini-3-pro-image") { + return 60, false + } + return 6, false + } + return 4, false + } + if strings.HasPrefix(name, "command") { + switch name { + case "command-r": + return 3, true + case "command-r-plus": + return 5, true + case "command-r-08-2024": + return 4, true + case "command-r-plus-08-2024": + return 4, true + default: + return 4, false + } + } + // hint 只给官方上4倍率,由于开源模型供应商自行定价,不对其进行补全倍率进行强制对齐 + if strings.HasPrefix(name, "ERNIE-Speed-") { + return 2, true + } else if strings.HasPrefix(name, "ERNIE-Lite-") { + return 2, true + } else if strings.HasPrefix(name, "ERNIE-Character") { + return 2, true + } else if strings.HasPrefix(name, "ERNIE-Functions") { + return 2, true + } + switch name { + case "llama2-70b-4096": + return 0.8 / 0.64, true + case "llama3-8b-8192": + return 2, true + case "llama3-70b-8192": + return 0.79 / 0.59, true + } + return 1, false +} + +func GetAudioRatio(name string) float64 { + audioRatioMapMutex.RLock() + defer audioRatioMapMutex.RUnlock() + name = FormatMatchingModelName(name) + if ratio, ok := audioRatioMap[name]; ok { + return ratio + } + return 20 +} + +func GetAudioCompletionRatio(name string) float64 { + audioCompletionRatioMapMutex.RLock() + defer audioCompletionRatioMapMutex.RUnlock() + name = FormatMatchingModelName(name) + if ratio, ok := audioCompletionRatioMap[name]; ok { + + return ratio + } + return 2 +} + +func ModelRatio2JSONString() string { + modelRatioMapMutex.RLock() + defer modelRatioMapMutex.RUnlock() + + jsonBytes, err := common.Marshal(modelRatioMap) + if err != nil { + common.SysError("error marshalling model ratio: " + err.Error()) + } + return string(jsonBytes) +} + +var defaultImageRatio = map[string]float64{ + "gpt-image-1": 2, +} +var imageRatioMap map[string]float64 +var imageRatioMapMutex sync.RWMutex +var ( + audioRatioMap map[string]float64 = nil + audioRatioMapMutex = sync.RWMutex{} +) +var ( + audioCompletionRatioMap map[string]float64 = nil + audioCompletionRatioMapMutex = sync.RWMutex{} +) + +func ImageRatio2JSONString() string { + imageRatioMapMutex.RLock() + defer imageRatioMapMutex.RUnlock() + jsonBytes, err := common.Marshal(imageRatioMap) + if err != nil { + common.SysError("error marshalling cache ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateImageRatioByJSONString(jsonStr string) error { + imageRatioMapMutex.Lock() + defer imageRatioMapMutex.Unlock() + imageRatioMap = make(map[string]float64) + return common.Unmarshal([]byte(jsonStr), &imageRatioMap) +} + +func GetImageRatio(name string) (float64, bool) { + imageRatioMapMutex.RLock() + defer imageRatioMapMutex.RUnlock() + ratio, ok := imageRatioMap[name] + if !ok { + return 1, false // Default to 1 if not found + } + return ratio, true +} + +func AudioRatio2JSONString() string { + audioRatioMapMutex.RLock() + defer audioRatioMapMutex.RUnlock() + jsonBytes, err := common.Marshal(audioRatioMap) + if err != nil { + common.SysError("error marshalling audio ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateAudioRatioByJSONString(jsonStr string) error { + + tmp := make(map[string]float64) + if err := common.Unmarshal([]byte(jsonStr), &tmp); err != nil { + return err + } + audioRatioMapMutex.Lock() + audioRatioMap = tmp + audioRatioMapMutex.Unlock() + InvalidateExposedDataCache() + return nil +} + +func GetAudioRatioCopy() map[string]float64 { + audioRatioMapMutex.RLock() + defer audioRatioMapMutex.RUnlock() + copyMap := make(map[string]float64, len(audioRatioMap)) + for k, v := range audioRatioMap { + copyMap[k] = v + } + return copyMap +} + +func AudioCompletionRatio2JSONString() string { + audioCompletionRatioMapMutex.RLock() + defer audioCompletionRatioMapMutex.RUnlock() + jsonBytes, err := common.Marshal(audioCompletionRatioMap) + if err != nil { + common.SysError("error marshalling audio completion ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateAudioCompletionRatioByJSONString(jsonStr string) error { + tmp := make(map[string]float64) + if err := common.Unmarshal([]byte(jsonStr), &tmp); err != nil { + return err + } + audioCompletionRatioMapMutex.Lock() + audioCompletionRatioMap = tmp + audioCompletionRatioMapMutex.Unlock() + InvalidateExposedDataCache() + return nil +} + +func GetAudioCompletionRatioCopy() map[string]float64 { + audioCompletionRatioMapMutex.RLock() + defer audioCompletionRatioMapMutex.RUnlock() + copyMap := make(map[string]float64, len(audioCompletionRatioMap)) + for k, v := range audioCompletionRatioMap { + copyMap[k] = v + } + return copyMap +} + +func GetModelRatioCopy() map[string]float64 { + modelRatioMapMutex.RLock() + defer modelRatioMapMutex.RUnlock() + copyMap := make(map[string]float64, len(modelRatioMap)) + for k, v := range modelRatioMap { + copyMap[k] = v + } + return copyMap +} + +func GetModelPriceCopy() map[string]float64 { + modelPriceMapMutex.RLock() + defer modelPriceMapMutex.RUnlock() + copyMap := make(map[string]float64, len(modelPriceMap)) + for k, v := range modelPriceMap { + copyMap[k] = v + } + return copyMap +} + +func GetCompletionRatioCopy() map[string]float64 { + CompletionRatioMutex.RLock() + defer CompletionRatioMutex.RUnlock() + copyMap := make(map[string]float64, len(CompletionRatio)) + for k, v := range CompletionRatio { + copyMap[k] = v + } + return copyMap +} + +// 转换模型名,减少渠道必须配置各种带参数模型 +func FormatMatchingModelName(name string) string { + + if strings.HasPrefix(name, "gemini-2.5-flash-lite") { + name = handleThinkingBudgetModel(name, "gemini-2.5-flash-lite", "gemini-2.5-flash-lite-thinking-*") + } else if strings.HasPrefix(name, "gemini-2.5-flash") { + name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*") + } else if strings.HasPrefix(name, "gemini-2.5-pro") { + name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*") + } + + if strings.HasPrefix(name, "gpt-4-gizmo") { + name = "gpt-4-gizmo-*" + } + if strings.HasPrefix(name, "gpt-4o-gizmo") { + name = "gpt-4o-gizmo-*" + } + return name +} + +// result: 倍率or价格, usePrice, exist +func GetModelRatioOrPrice(model string) (float64, bool, bool) { // price or ratio + price, usePrice := GetModelPrice(model, false) + if usePrice { + return price, true, true + } + modelRatio, success, _ := GetModelRatio(model) + if success { + return modelRatio, false, true + } + return 37.5, false, false +} diff --git a/setting/sensitive.go b/setting/sensitive.go new file mode 100644 index 0000000000000000000000000000000000000000..86f9be9a6eb38683888ccf9cefd1d20ca4ac3e0f --- /dev/null +++ b/setting/sensitive.go @@ -0,0 +1,43 @@ +package setting + +import "strings" + +var CheckSensitiveEnabled = true +var CheckSensitiveOnPromptEnabled = true + +//var CheckSensitiveOnCompletionEnabled = true + +// StopOnSensitiveEnabled 如果检测到敏感词,是否立刻停止生成,否则替换敏感词 +var StopOnSensitiveEnabled = true + +// StreamCacheQueueLength 流模式缓存队列长度,0表示无缓存 +var StreamCacheQueueLength = 0 + +// SensitiveWords 敏感词 +// var SensitiveWords []string +var SensitiveWords = []string{ + "test_sensitive", +} + +func SensitiveWordsToString() string { + return strings.Join(SensitiveWords, "\n") +} + +func SensitiveWordsFromString(s string) { + SensitiveWords = []string{} + sw := strings.Split(s, "\n") + for _, w := range sw { + w = strings.TrimSpace(w) + if w != "" { + SensitiveWords = append(SensitiveWords, w) + } + } +} + +func ShouldCheckPromptSensitive() bool { + return CheckSensitiveEnabled && CheckSensitiveOnPromptEnabled +} + +//func ShouldCheckCompletionSensitive() bool { +// return CheckSensitiveEnabled && CheckSensitiveOnCompletionEnabled +//} diff --git a/setting/system_setting/discord.go b/setting/system_setting/discord.go new file mode 100644 index 0000000000000000000000000000000000000000..f4e763ffadaf5b60c954360e62d6cf63a655c664 --- /dev/null +++ b/setting/system_setting/discord.go @@ -0,0 +1,21 @@ +package system_setting + +import "github.com/QuantumNous/new-api/setting/config" + +type DiscordSettings struct { + Enabled bool `json:"enabled"` + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +// 默认配置 +var defaultDiscordSettings = DiscordSettings{} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("discord", &defaultDiscordSettings) +} + +func GetDiscordSettings() *DiscordSettings { + return &defaultDiscordSettings +} diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go new file mode 100644 index 0000000000000000000000000000000000000000..078696195fc4682d5c510feae3f4939c9ead5d6b --- /dev/null +++ b/setting/system_setting/fetch_setting.go @@ -0,0 +1,34 @@ +package system_setting + +import "github.com/QuantumNous/new-api/setting/config" + +type FetchSetting struct { + EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护 + AllowPrivateIp bool `json:"allow_private_ip"` + DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式 + IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式 + DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com + IpList []string `json:"ip_list"` // CIDR format + AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000 + ApplyIPFilterForDomain bool `json:"apply_ip_filter_for_domain"` // 对域名启用IP过滤(实验性) +} + +var defaultFetchSetting = FetchSetting{ + EnableSSRFProtection: true, // 默认开启SSRF防护 + AllowPrivateIp: false, + DomainFilterMode: false, + IpFilterMode: false, + DomainList: []string{}, + IpList: []string{}, + AllowedPorts: []string{"80", "443", "8080", "8443"}, + ApplyIPFilterForDomain: false, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("fetch_setting", &defaultFetchSetting) +} + +func GetFetchSetting() *FetchSetting { + return &defaultFetchSetting +} diff --git a/setting/system_setting/legal.go b/setting/system_setting/legal.go new file mode 100644 index 0000000000000000000000000000000000000000..cc84d4085cc29d9b735108aa8ca4ff7e53960559 --- /dev/null +++ b/setting/system_setting/legal.go @@ -0,0 +1,21 @@ +package system_setting + +import "github.com/QuantumNous/new-api/setting/config" + +type LegalSettings struct { + UserAgreement string `json:"user_agreement"` + PrivacyPolicy string `json:"privacy_policy"` +} + +var defaultLegalSettings = LegalSettings{ + UserAgreement: "", + PrivacyPolicy: "", +} + +func init() { + config.GlobalConfig.Register("legal", &defaultLegalSettings) +} + +func GetLegalSettings() *LegalSettings { + return &defaultLegalSettings +} diff --git a/setting/system_setting/oidc.go b/setting/system_setting/oidc.go new file mode 100644 index 0000000000000000000000000000000000000000..307d3b4a4933e51215801f489f469454db34db5c --- /dev/null +++ b/setting/system_setting/oidc.go @@ -0,0 +1,25 @@ +package system_setting + +import "github.com/QuantumNous/new-api/setting/config" + +type OIDCSettings struct { + Enabled bool `json:"enabled"` + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` + WellKnown string `json:"well_known"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"user_info_endpoint"` +} + +// 默认配置 +var defaultOIDCSettings = OIDCSettings{} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("oidc", &defaultOIDCSettings) +} + +func GetOIDCSettings() *OIDCSettings { + return &defaultOIDCSettings +} diff --git a/setting/system_setting/passkey.go b/setting/system_setting/passkey.go new file mode 100644 index 0000000000000000000000000000000000000000..41855898c674c89151eeb376675bbd126e62aec0 --- /dev/null +++ b/setting/system_setting/passkey.go @@ -0,0 +1,50 @@ +package system_setting + +import ( + "net/url" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/config" +) + +type PasskeySettings struct { + Enabled bool `json:"enabled"` + RPDisplayName string `json:"rp_display_name"` + RPID string `json:"rp_id"` + Origins string `json:"origins"` + AllowInsecureOrigin bool `json:"allow_insecure_origin"` + UserVerification string `json:"user_verification"` + AttachmentPreference string `json:"attachment_preference"` +} + +var defaultPasskeySettings = PasskeySettings{ + Enabled: false, + RPDisplayName: common.SystemName, + RPID: "", + Origins: "", + AllowInsecureOrigin: false, + UserVerification: "preferred", + AttachmentPreference: "", +} + +func init() { + config.GlobalConfig.Register("passkey", &defaultPasskeySettings) +} + +func GetPasskeySettings() *PasskeySettings { + if defaultPasskeySettings.RPID == "" && ServerAddress != "" { + // 从ServerAddress提取域名作为RPID + // ServerAddress可能是 "https://newapi.pro" 这种格式 + serverAddr := strings.TrimSpace(ServerAddress) + if parsed, err := url.Parse(serverAddr); err == nil && parsed.Host != "" { + defaultPasskeySettings.RPID = parsed.Host + } else { + defaultPasskeySettings.RPID = serverAddr + } + } + if defaultPasskeySettings.Origins == "" || defaultPasskeySettings.Origins == "[]" { + defaultPasskeySettings.Origins = ServerAddress + } + return &defaultPasskeySettings +} diff --git a/setting/system_setting/system_setting_old.go b/setting/system_setting/system_setting_old.go new file mode 100644 index 0000000000000000000000000000000000000000..4e0f1a502087e25837fed542698d351887e31cd7 --- /dev/null +++ b/setting/system_setting/system_setting_old.go @@ -0,0 +1,10 @@ +package system_setting + +var ServerAddress = "http://localhost:3000" +var WorkerUrl = "" +var WorkerValidKey = "" +var WorkerAllowHttpImageRequestEnabled = false + +func EnableWorker() bool { + return WorkerUrl != "" +} diff --git a/setting/user_usable_group.go b/setting/user_usable_group.go new file mode 100644 index 0000000000000000000000000000000000000000..eb04b7f3053446989c430e74fa18efa3fd890474 --- /dev/null +++ b/setting/user_usable_group.go @@ -0,0 +1,54 @@ +package setting + +import ( + "encoding/json" + "sync" + + "github.com/QuantumNous/new-api/common" +) + +var userUsableGroups = map[string]string{ + "default": "默认分组", + "vip": "vip分组", +} +var userUsableGroupsMutex sync.RWMutex + +func GetUserUsableGroupsCopy() map[string]string { + userUsableGroupsMutex.RLock() + defer userUsableGroupsMutex.RUnlock() + + copyUserUsableGroups := make(map[string]string) + for k, v := range userUsableGroups { + copyUserUsableGroups[k] = v + } + return copyUserUsableGroups +} + +func UserUsableGroups2JSONString() string { + userUsableGroupsMutex.RLock() + defer userUsableGroupsMutex.RUnlock() + + jsonBytes, err := json.Marshal(userUsableGroups) + if err != nil { + common.SysLog("error marshalling user groups: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateUserUsableGroupsByJSONString(jsonStr string) error { + userUsableGroupsMutex.Lock() + defer userUsableGroupsMutex.Unlock() + + userUsableGroups = make(map[string]string) + return json.Unmarshal([]byte(jsonStr), &userUsableGroups) +} + +func GetUsableGroupDescription(groupName string) string { + userUsableGroupsMutex.RLock() + defer userUsableGroupsMutex.RUnlock() + + if desc, ok := userUsableGroups[groupName]; ok { + return desc + } + return groupName +} diff --git a/types/channel_error.go b/types/channel_error.go new file mode 100644 index 0000000000000000000000000000000000000000..f2d72bf536e332c5393d25276da34a1eebf1f48d --- /dev/null +++ b/types/channel_error.go @@ -0,0 +1,21 @@ +package types + +type ChannelError struct { + ChannelId int `json:"channel_id"` + ChannelType int `json:"channel_type"` + ChannelName string `json:"channel_name"` + IsMultiKey bool `json:"is_multi_key"` + AutoBan bool `json:"auto_ban"` + UsingKey string `json:"using_key"` +} + +func NewChannelError(channelId int, channelType int, channelName string, isMultiKey bool, usingKey string, autoBan bool) *ChannelError { + return &ChannelError{ + ChannelId: channelId, + ChannelType: channelType, + ChannelName: channelName, + IsMultiKey: isMultiKey, + AutoBan: autoBan, + UsingKey: usingKey, + } +} diff --git a/types/error.go b/types/error.go new file mode 100644 index 0000000000000000000000000000000000000000..9c12034e1e4f030a61bd172578ec3d81d9f6654a --- /dev/null +++ b/types/error.go @@ -0,0 +1,364 @@ +package types + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" +) + +type OpenAIError struct { + Message string `json:"message"` + Type string `json:"type"` + Param string `json:"param"` + Code any `json:"code"` +} + +type ClaudeError struct { + Type string `json:"type,omitempty"` + Message string `json:"message,omitempty"` +} + +type ErrorType string + +const ( + ErrorTypeNewAPIError ErrorType = "new_api_error" + ErrorTypeOpenAIError ErrorType = "openai_error" + ErrorTypeClaudeError ErrorType = "claude_error" + ErrorTypeMidjourneyError ErrorType = "midjourney_error" + ErrorTypeGeminiError ErrorType = "gemini_error" + ErrorTypeRerankError ErrorType = "rerank_error" + ErrorTypeUpstreamError ErrorType = "upstream_error" +) + +type ErrorCode string + +const ( + ErrorCodeInvalidRequest ErrorCode = "invalid_request" + ErrorCodeSensitiveWordsDetected ErrorCode = "sensitive_words_detected" + + // new api error + ErrorCodeCountTokenFailed ErrorCode = "count_token_failed" + ErrorCodeModelPriceError ErrorCode = "model_price_error" + ErrorCodeInvalidApiType ErrorCode = "invalid_api_type" + ErrorCodeJsonMarshalFailed ErrorCode = "json_marshal_failed" + ErrorCodeDoRequestFailed ErrorCode = "do_request_failed" + ErrorCodeGetChannelFailed ErrorCode = "get_channel_failed" + ErrorCodeGenRelayInfoFailed ErrorCode = "gen_relay_info_failed" + + // channel error + ErrorCodeChannelNoAvailableKey ErrorCode = "channel:no_available_key" + ErrorCodeChannelParamOverrideInvalid ErrorCode = "channel:param_override_invalid" + ErrorCodeChannelHeaderOverrideInvalid ErrorCode = "channel:header_override_invalid" + ErrorCodeChannelModelMappedError ErrorCode = "channel:model_mapped_error" + ErrorCodeChannelAwsClientError ErrorCode = "channel:aws_client_error" + ErrorCodeChannelInvalidKey ErrorCode = "channel:invalid_key" + ErrorCodeChannelResponseTimeExceeded ErrorCode = "channel:response_time_exceeded" + + // client request error + ErrorCodeReadRequestBodyFailed ErrorCode = "read_request_body_failed" + ErrorCodeConvertRequestFailed ErrorCode = "convert_request_failed" + ErrorCodeAccessDenied ErrorCode = "access_denied" + + // request error + ErrorCodeBadRequestBody ErrorCode = "bad_request_body" + + // response error + ErrorCodeReadResponseBodyFailed ErrorCode = "read_response_body_failed" + ErrorCodeBadResponseStatusCode ErrorCode = "bad_response_status_code" + ErrorCodeBadResponse ErrorCode = "bad_response" + ErrorCodeBadResponseBody ErrorCode = "bad_response_body" + ErrorCodeEmptyResponse ErrorCode = "empty_response" + ErrorCodeAwsInvokeError ErrorCode = "aws_invoke_error" + ErrorCodeModelNotFound ErrorCode = "model_not_found" + ErrorCodePromptBlocked ErrorCode = "prompt_blocked" + + // sql error + ErrorCodeQueryDataError ErrorCode = "query_data_error" + ErrorCodeUpdateDataError ErrorCode = "update_data_error" + + // quota error + ErrorCodeInsufficientUserQuota ErrorCode = "insufficient_user_quota" + ErrorCodePreConsumeTokenQuotaFailed ErrorCode = "pre_consume_token_quota_failed" +) + +type NewAPIError struct { + Err error + RelayError any + skipRetry bool + recordErrorLog *bool + errorType ErrorType + errorCode ErrorCode + StatusCode int +} + +func (e *NewAPIError) GetErrorCode() ErrorCode { + if e == nil { + return "" + } + return e.errorCode +} + +func (e *NewAPIError) GetErrorType() ErrorType { + if e == nil { + return "" + } + return e.errorType +} + +func (e *NewAPIError) Error() string { + if e == nil { + return "" + } + if e.Err == nil { + // fallback message when underlying error is missing + return string(e.errorCode) + } + return e.Err.Error() +} + +func (e *NewAPIError) MaskSensitiveError() string { + if e == nil { + return "" + } + if e.Err == nil { + return string(e.errorCode) + } + errStr := e.Err.Error() + if e.errorCode == ErrorCodeCountTokenFailed { + return errStr + } + return common.MaskSensitiveInfo(errStr) +} + +func (e *NewAPIError) SetMessage(message string) { + e.Err = errors.New(message) +} + +func (e *NewAPIError) ToOpenAIError() OpenAIError { + var result OpenAIError + switch e.errorType { + case ErrorTypeOpenAIError: + if openAIError, ok := e.RelayError.(OpenAIError); ok { + result = openAIError + } + case ErrorTypeClaudeError: + if claudeError, ok := e.RelayError.(ClaudeError); ok { + result = OpenAIError{ + Message: e.Error(), + Type: claudeError.Type, + Param: "", + Code: e.errorCode, + } + } + default: + result = OpenAIError{ + Message: e.Error(), + Type: string(e.errorType), + Param: "", + Code: e.errorCode, + } + } + if e.errorCode != ErrorCodeCountTokenFailed { + result.Message = common.MaskSensitiveInfo(result.Message) + } + if result.Message == "" { + result.Message = string(e.errorType) + } + return result +} + +func (e *NewAPIError) ToClaudeError() ClaudeError { + var result ClaudeError + switch e.errorType { + case ErrorTypeOpenAIError: + if openAIError, ok := e.RelayError.(OpenAIError); ok { + result = ClaudeError{ + Message: e.Error(), + Type: fmt.Sprintf("%v", openAIError.Code), + } + } + case ErrorTypeClaudeError: + if claudeError, ok := e.RelayError.(ClaudeError); ok { + result = claudeError + } + default: + result = ClaudeError{ + Message: e.Error(), + Type: string(e.errorType), + } + } + if e.errorCode != ErrorCodeCountTokenFailed { + result.Message = common.MaskSensitiveInfo(result.Message) + } + if result.Message == "" { + result.Message = string(e.errorType) + } + return result +} + +type NewAPIErrorOptions func(*NewAPIError) + +func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPIError { + var newErr *NewAPIError + // 保留深层传递的 new err + if errors.As(err, &newErr) { + for _, op := range ops { + op(newErr) + } + return newErr + } + e := &NewAPIError{ + Err: err, + RelayError: nil, + errorType: ErrorTypeNewAPIError, + StatusCode: http.StatusInternalServerError, + errorCode: errorCode, + } + for _, op := range ops { + op(e) + } + return e +} + +func NewOpenAIError(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { + var newErr *NewAPIError + // 保留深层传递的 new err + if errors.As(err, &newErr) { + if newErr.RelayError == nil { + openaiError := OpenAIError{ + Message: newErr.Error(), + Type: string(errorCode), + Code: errorCode, + } + newErr.RelayError = openaiError + } + for _, op := range ops { + op(newErr) + } + return newErr + } + openaiError := OpenAIError{ + Message: err.Error(), + Type: string(errorCode), + Code: errorCode, + } + return WithOpenAIError(openaiError, statusCode, ops...) +} + +func InitOpenAIError(errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { + openaiError := OpenAIError{ + Type: string(errorCode), + Code: errorCode, + } + return WithOpenAIError(openaiError, statusCode, ops...) +} + +func NewErrorWithStatusCode(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { + e := &NewAPIError{ + Err: err, + RelayError: OpenAIError{ + Message: err.Error(), + Type: string(errorCode), + }, + errorType: ErrorTypeNewAPIError, + StatusCode: statusCode, + errorCode: errorCode, + } + for _, op := range ops { + op(e) + } + + return e +} + +func WithOpenAIError(openAIError OpenAIError, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { + code, ok := openAIError.Code.(string) + if !ok { + if openAIError.Code != nil { + code = fmt.Sprintf("%v", openAIError.Code) + } else { + code = "unknown_error" + } + } + if openAIError.Type == "" { + openAIError.Type = "upstream_error" + } + e := &NewAPIError{ + RelayError: openAIError, + errorType: ErrorTypeOpenAIError, + StatusCode: statusCode, + Err: errors.New(openAIError.Message), + errorCode: ErrorCode(code), + } + for _, op := range ops { + op(e) + } + return e +} + +func WithClaudeError(claudeError ClaudeError, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { + if claudeError.Type == "" { + claudeError.Type = "upstream_error" + } + e := &NewAPIError{ + RelayError: claudeError, + errorType: ErrorTypeClaudeError, + StatusCode: statusCode, + Err: errors.New(claudeError.Message), + errorCode: ErrorCode(claudeError.Type), + } + for _, op := range ops { + op(e) + } + return e +} + +func IsChannelError(err *NewAPIError) bool { + if err == nil { + return false + } + return strings.HasPrefix(string(err.errorCode), "channel:") +} + +func IsSkipRetryError(err *NewAPIError) bool { + if err == nil { + return false + } + + return err.skipRetry +} + +func ErrOptionWithSkipRetry() NewAPIErrorOptions { + return func(e *NewAPIError) { + e.skipRetry = true + } +} + +func ErrOptionWithNoRecordErrorLog() NewAPIErrorOptions { + return func(e *NewAPIError) { + e.recordErrorLog = common.GetPointer(false) + } +} + +func ErrOptionWithHideErrMsg(replaceStr string) NewAPIErrorOptions { + return func(e *NewAPIError) { + if common.DebugEnabled { + fmt.Printf("ErrOptionWithHideErrMsg: %s, origin error: %s", replaceStr, e.Err) + } + e.Err = errors.New(replaceStr) + } +} + +func IsRecordErrorLog(e *NewAPIError) bool { + if e == nil { + return false + } + if e.recordErrorLog == nil { + // default to true if not set + return true + } + return *e.recordErrorLog +} diff --git a/types/file_data.go b/types/file_data.go new file mode 100644 index 0000000000000000000000000000000000000000..f1c82e21ec1fd494b8efb8d84776adbba8626f46 --- /dev/null +++ b/types/file_data.go @@ -0,0 +1,8 @@ +package types + +type LocalFileData struct { + MimeType string + Base64Data string + Url string + Size int64 +} diff --git a/types/price_data.go b/types/price_data.go new file mode 100644 index 0000000000000000000000000000000000000000..93044f86525359d3b4fb43e64233da9a0c4dcc7a --- /dev/null +++ b/types/price_data.go @@ -0,0 +1,37 @@ +package types + +import "fmt" + +type GroupRatioInfo struct { + GroupRatio float64 + GroupSpecialRatio float64 + HasSpecialRatio bool +} + +type PriceData struct { + FreeModel bool + ModelPrice float64 + ModelRatio float64 + CompletionRatio float64 + CacheRatio float64 + CacheCreationRatio float64 + CacheCreation5mRatio float64 + CacheCreation1hRatio float64 + ImageRatio float64 + AudioRatio float64 + AudioCompletionRatio float64 + OtherRatios map[string]float64 + UsePrice bool + QuotaToPreConsume int // 预消耗额度 + GroupRatioInfo GroupRatioInfo +} + +type PerCallPriceData struct { + ModelPrice float64 + Quota int + GroupRatioInfo GroupRatioInfo +} + +func (p PriceData) ToSetting() string { + return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio) +} diff --git a/types/relay_format.go b/types/relay_format.go new file mode 100644 index 0000000000000000000000000000000000000000..6d94a70bcae02f1e6fcc3fd46ca67d39e1d7a5cb --- /dev/null +++ b/types/relay_format.go @@ -0,0 +1,18 @@ +package types + +type RelayFormat string + +const ( + RelayFormatOpenAI RelayFormat = "openai" + RelayFormatClaude = "claude" + RelayFormatGemini = "gemini" + RelayFormatOpenAIResponses = "openai_responses" + RelayFormatOpenAIAudio = "openai_audio" + RelayFormatOpenAIImage = "openai_image" + RelayFormatOpenAIRealtime = "openai_realtime" + RelayFormatRerank = "rerank" + RelayFormatEmbedding = "embedding" + + RelayFormatTask = "task" + RelayFormatMjProxy = "mj_proxy" +) diff --git a/types/request_meta.go b/types/request_meta.go new file mode 100644 index 0000000000000000000000000000000000000000..18f80832be79f21de2e1b62628f0795dfc59df93 --- /dev/null +++ b/types/request_meta.go @@ -0,0 +1,46 @@ +package types + +type FileType string + +const ( + FileTypeImage FileType = "image" // Image file type + FileTypeAudio FileType = "audio" // Audio file type + FileTypeVideo FileType = "video" // Video file type + FileTypeFile FileType = "file" // Generic file type +) + +type TokenType string + +const ( + TokenTypeTextNumber TokenType = "text_number" // Text or number tokens + TokenTypeTokenizer TokenType = "tokenizer" // Tokenizer tokens + TokenTypeImage TokenType = "image" // Image tokens +) + +type TokenCountMeta struct { + TokenType TokenType `json:"token_type,omitempty"` // Type of tokens used in the request + CombineText string `json:"combine_text,omitempty"` // Combined text from all messages + ToolsCount int `json:"tools_count,omitempty"` // Number of tools used + NameCount int `json:"name_count,omitempty"` // Number of names in the request + MessagesCount int `json:"messages_count,omitempty"` // Number of messages in the request + Files []*FileMeta `json:"files,omitempty"` // List of files, each with type and content + MaxTokens int `json:"max_tokens,omitempty"` // Maximum tokens allowed in the request + + ImagePriceRatio float64 `json:"image_ratio,omitempty"` // Ratio for image size, if applicable + //IsStreaming bool `json:"is_streaming,omitempty"` // Indicates if the request is streaming +} + +type FileMeta struct { + FileType + MimeType string + OriginData string // url or base64 data + Detail string + ParsedData *LocalFileData +} + +type RequestMeta struct { + OriginalModelName string `json:"original_model_name"` + UserUsingGroup string `json:"user_using_group"` + PromptTokens int `json:"prompt_tokens"` + PreConsumedQuota int `json:"pre_consumed_quota"` +} diff --git a/types/rw_map.go b/types/rw_map.go new file mode 100644 index 0000000000000000000000000000000000000000..12c9cbc7fe42a3c858b1395480363754c74031f5 --- /dev/null +++ b/types/rw_map.go @@ -0,0 +1,82 @@ +package types + +import ( + "sync" + + "github.com/QuantumNous/new-api/common" +) + +type RWMap[K comparable, V any] struct { + data map[K]V + mutex sync.RWMutex +} + +func (m *RWMap[K, V]) UnmarshalJSON(b []byte) error { + m.mutex.Lock() + defer m.mutex.Unlock() + m.data = make(map[K]V) + return common.Unmarshal(b, &m.data) +} + +func (m *RWMap[K, V]) MarshalJSON() ([]byte, error) { + m.mutex.RLock() + defer m.mutex.RUnlock() + return common.Marshal(m.data) +} + +func NewRWMap[K comparable, V any]() *RWMap[K, V] { + return &RWMap[K, V]{ + data: make(map[K]V), + } +} + +func (m *RWMap[K, V]) Get(key K) (V, bool) { + m.mutex.RLock() + defer m.mutex.RUnlock() + value, exists := m.data[key] + return value, exists +} + +func (m *RWMap[K, V]) Set(key K, value V) { + m.mutex.Lock() + defer m.mutex.Unlock() + m.data[key] = value +} + +func (m *RWMap[K, V]) AddAll(other map[K]V) { + m.mutex.Lock() + defer m.mutex.Unlock() + for k, v := range other { + m.data[k] = v + } +} + +func (m *RWMap[K, V]) Clear() { + m.mutex.Lock() + defer m.mutex.Unlock() + m.data = make(map[K]V) +} + +// ReadAll returns a copy of the entire map. +func (m *RWMap[K, V]) ReadAll() map[K]V { + m.mutex.RLock() + defer m.mutex.RUnlock() + copiedMap := make(map[K]V) + for k, v := range m.data { + copiedMap[k] = v + } + return copiedMap +} + +func (m *RWMap[K, V]) Len() int { + m.mutex.RLock() + defer m.mutex.RUnlock() + return len(m.data) +} + +func LoadFromJsonString[K comparable, V any](m *RWMap[K, V], jsonStr string) error { + m.mutex.Lock() + defer m.mutex.Unlock() + m.data = make(map[K]V) + return common.Unmarshal([]byte(jsonStr), &m.data) +} diff --git a/types/set.go b/types/set.go new file mode 100644 index 0000000000000000000000000000000000000000..db6b0272cbc44a50bf18c77e89598cf47dda4ae4 --- /dev/null +++ b/types/set.go @@ -0,0 +1,42 @@ +package types + +type Set[T comparable] struct { + items map[T]struct{} +} + +// NewSet 创建并返回一个新的 Set +func NewSet[T comparable]() *Set[T] { + return &Set[T]{ + items: make(map[T]struct{}), + } +} + +func (s *Set[T]) Add(item T) { + s.items[item] = struct{}{} +} + +// Remove 从 Set 中移除一个元素 +func (s *Set[T]) Remove(item T) { + delete(s.items, item) +} + +// Contains 检查 Set 是否包含某个元素 +func (s *Set[T]) Contains(item T) bool { + _, exists := s.items[item] + return exists +} + +// Len 返回 Set 中元素的数量 +func (s *Set[T]) Len() int { + return len(s.items) +} + +// Items 返回 Set 中所有元素组成的切片 +// 注意:由于 map 的无序性,返回的切片元素顺序是随机的 +func (s *Set[T]) Items() []T { + items := make([]T, 0, s.Len()) + for item := range s.items { + items = append(items, item) + } + return items +} diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 0000000000000000000000000000000000000000..b1afd96f5b724c62dffa579f0d7354bbd3accf53 --- /dev/null +++ b/web/.eslintrc.cjs @@ -0,0 +1,42 @@ +module.exports = { + root: true, + env: { browser: true, es2021: true, node: true }, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + ecmaFeatures: { jsx: true }, + }, + plugins: ['header', 'react-hooks'], + overrides: [ + { + files: ['**/*.{js,jsx}'], + rules: { + 'header/header': [ + 2, + 'block', + [ + '', + 'Copyright (C) 2025 QuantumNous', + '', + '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 support@quantumnous.com', + '', + ], + ], + 'no-multiple-empty-lines': ['error', { max: 1 }], + }, + }, + ], +}; diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..2b5bba767be29f53c9efa0b00b8d7f61059bd5d2 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,26 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.idea +package-lock.json +yarn.lock \ No newline at end of file diff --git a/web/.prettierrc.mjs b/web/.prettierrc.mjs new file mode 100644 index 0000000000000000000000000000000000000000..5140bc3e923d98789051fd019bba2239b222e2f1 --- /dev/null +++ b/web/.prettierrc.mjs @@ -0,0 +1 @@ +module.exports = require('@so1ve/prettier-config'); diff --git a/web/bun.lock b/web/bun.lock new file mode 100644 index 0000000000000000000000000000000000000000..fdec073ec7fdf025538790ff3ef0b756d0399b13 --- /dev/null +++ b/web/bun.lock @@ -0,0 +1,2336 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "react-template", + "dependencies": { + "@douyinfe/semi-icons": "^2.63.1", + "@douyinfe/semi-ui": "^2.69.1", + "@lobehub/icons": "^2.0.0", + "@visactor/react-vchart": "~1.8.8", + "@visactor/vchart": "~1.8.8", + "@visactor/vchart-semi-theme": "~1.8.8", + "axios": "1.12.0", + "clsx": "^2.1.1", + "country-flag-icons": "^1.5.19", + "dayjs": "^1.11.11", + "history": "^5.3.0", + "i18next": "^23.16.8", + "i18next-browser-languagedetector": "^7.2.0", + "katex": "^0.16.22", + "lucide-react": "^0.511.0", + "marked": "^4.1.1", + "mermaid": "^11.6.0", + "qrcode.react": "^4.2.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", + "react-fireworks": "^1.0.4", + "react-i18next": "^13.0.0", + "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", + "react-router-dom": "^6.3.0", + "react-telegram-login": "^1.1.2", + "react-toastify": "^9.0.8", + "react-turnstile": "^1.0.5", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "sse.js": "^2.6.0", + "unist-util-visit": "^5.0.0", + "use-debounce": "^10.0.4", + }, + "devDependencies": { + "@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6", + "@so1ve/prettier-config": "^3.1.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.21", + "eslint": "8.57.0", + "eslint-plugin-header": "^3.1.1", + "eslint-plugin-react-hooks": "^5.2.0", + "i18next-cli": "^1.10.3", + "postcss": "^8.5.3", + "prettier": "^3.0.0", + "tailwindcss": "^3", + "typescript": "4.4.2", + "vite": "^5.2.0", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@ant-design/colors": ["@ant-design/colors@7.2.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6" } }, "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ=="], + + "@ant-design/cssinjs": ["@ant-design/cssinjs@1.23.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "classnames": "^2.3.1", "csstype": "^3.1.3", "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-7GAg9bD/iC9ikWatU9ym+P9ugJhi/WbsTWzcKN6T4gU0aehsprtke1UAaaSxxkjjmkJb3llet/rbUSLPgwlY4w=="], + + "@ant-design/cssinjs-utils": ["@ant-design/cssinjs-utils@1.1.3", "", { "dependencies": { "@ant-design/cssinjs": "^1.21.0", "@babel/runtime": "^7.23.2", "rc-util": "^5.38.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg=="], + + "@ant-design/fast-color": ["@ant-design/fast-color@2.0.6", "", { "dependencies": { "@babel/runtime": "^7.24.7" } }, "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA=="], + + "@ant-design/icons": ["@ant-design/icons@5.6.1", "", { "dependencies": { "@ant-design/colors": "^7.0.0", "@ant-design/icons-svg": "^4.4.0", "@babel/runtime": "^7.24.8", "classnames": "^2.2.6", "rc-util": "^5.31.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg=="], + + "@ant-design/icons-svg": ["@ant-design/icons-svg@4.4.2", "", {}, "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="], + + "@ant-design/react-slick": ["@ant-design/react-slick@1.1.2", "", { "dependencies": { "@babel/runtime": "^7.10.4", "classnames": "^2.2.5", "json2mq": "^0.2.0", "resize-observer-polyfill": "^1.5.1", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": ">=16.9.0" } }, "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA=="], + + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + + "@antfu/utils": ["@antfu/utils@8.1.1", "", {}, "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ=="], + + "@astrojs/compiler": ["@astrojs/compiler@2.10.3", "", {}, "sha512-bL/O7YBxsFt55YHU021oL+xz+B/9HvGNId3F9xURN16aeqDK9juHGktdkCSXz+U4nqFACq6ZFvWomOzhV+zfPw=="], + + "@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="], + + "@babel/compat-data": ["@babel/compat-data@7.26.3", "", {}, "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g=="], + + "@babel/core": ["@babel/core@7.26.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.0", "@babel/generator": "^7.26.0", "@babel/helper-compilation-targets": "^7.25.9", "@babel/helper-module-transforms": "^7.26.0", "@babel/helpers": "^7.26.0", "@babel/parser": "^7.26.0", "@babel/template": "^7.25.9", "@babel/traverse": "^7.25.9", "@babel/types": "^7.26.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg=="], + + "@babel/generator": ["@babel/generator@7.26.3", "", { "dependencies": { "@babel/parser": "^7.26.3", "@babel/types": "^7.26.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.25.9", "", { "dependencies": { "@babel/compat-data": "^7.25.9", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ=="], + + "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.4", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.26.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9", "@babel/traverse": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.25.9", "", {}, "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.25.9", "", {}, "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw=="], + + "@babel/helpers": ["@babel/helpers@7.26.0", "", { "dependencies": { "@babel/template": "^7.25.9", "@babel/types": "^7.26.0" } }, "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw=="], + + "@babel/parser": ["@babel/parser@7.26.3", "", { "dependencies": { "@babel/types": "^7.26.3" }, "bin": "./bin/babel-parser.js" }, "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg=="], + + "@babel/plugin-transform-runtime": ["@babel/plugin-transform-runtime@7.27.1", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "babel-plugin-polyfill-corejs2": "^0.4.10", "babel-plugin-polyfill-corejs3": "^0.11.0", "babel-plugin-polyfill-regenerator": "^0.6.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TqGF3desVsTcp3WrJGj4HfKokfCXCLcHpt4PJF0D8/iT6LPd9RS82Upw3KPeyr6B22Lfd3DO8MVrmp0oRkUDdw=="], + + "@babel/runtime": ["@babel/runtime@7.26.0", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw=="], + + "@babel/template": ["@babel/template@7.25.9", "", { "dependencies": { "@babel/code-frame": "^7.25.9", "@babel/parser": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg=="], + + "@babel/traverse": ["@babel/traverse@7.26.4", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.3", "@babel/parser": "^7.26.3", "@babel/template": "^7.25.9", "@babel/types": "^7.26.3", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w=="], + + "@babel/types": ["@babel/types@7.26.3", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA=="], + + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], + + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], + + "@chevrotain/gast": ["@chevrotain/gast@11.0.3", "", { "dependencies": { "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q=="], + + "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.0.3", "", {}, "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA=="], + + "@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="], + + "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@7.0.2", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.0", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.0.7", "react": ">=16.8.0" } }, "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + + "@douyinfe/semi-animation": ["@douyinfe/semi-animation@2.72.2", "", { "dependencies": { "bezier-easing": "^2.1.0" } }, "sha512-MM2We1Nzvqa6uOrrWurUR+r5klOtOucpBHSjN13plVfZrd1VW8aIlwAyvqEntjOutOoVgnVwkeJHN1P56UV6dQ=="], + + "@douyinfe/semi-animation-react": ["@douyinfe/semi-animation-react@2.72.2", "", { "dependencies": { "@douyinfe/semi-animation": "2.72.2", "@douyinfe/semi-animation-styled": "2.72.2", "classnames": "^2.2.6" } }, "sha512-Iz2mDHDg8Gbur4pzqAyptkA6SK3LB5coGm5r/hevVNWif8Q7gDH9/UR/E9PAx1zORwlxov7BJxUMhrmgaHx7uw=="], + + "@douyinfe/semi-animation-styled": ["@douyinfe/semi-animation-styled@2.72.2", "", {}, "sha512-RKiHV71nWqpp/FiLDLNyw2CNrkR9W7qNnF/zkRosxRs5t4qRCtukdDaTNruuD2exekmCuejs+ClQi4AAwgkIYw=="], + + "@douyinfe/semi-foundation": ["@douyinfe/semi-foundation@2.72.2", "", { "dependencies": { "@douyinfe/semi-animation": "2.72.2", "@douyinfe/semi-json-viewer-core": "2.72.2", "@mdx-js/mdx": "^3.0.1", "async-validator": "^3.5.0", "classnames": "^2.2.6", "date-fns": "^2.29.3", "date-fns-tz": "^1.3.8", "fast-copy": "^3.0.1 ", "lodash": "^4.17.21", "lottie-web": "^5.12.2", "memoize-one": "^5.2.1", "prismjs": "^1.29.0", "remark-gfm": "^4.0.0", "scroll-into-view-if-needed": "^2.2.24" } }, "sha512-pIJIz5rrayVyx2Dk4ntCifet5ZL9bEeTRSnauQtKRxq15ZqT10IETeUha235NAXZr+qA8YGhY+v9dhCXM9SMNA=="], + + "@douyinfe/semi-icons": ["@douyinfe/semi-icons@2.72.2", "", { "dependencies": { "classnames": "^2.2.6" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-Jbq/U+/R+UWQyp6Wz19ZnSSwDob0f2m/7ZfXBLUgRGGaYrtDTW+RNY1yp7Y4JEvYIcYThPzrNa8WDga7eK//Ng=="], + + "@douyinfe/semi-illustrations": ["@douyinfe/semi-illustrations@2.72.2", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-Rp1JBcZaEFyIJ+LYfESIp4Xf0rv4h6Es+XOVHtjzuZLD+cSmlhf8zT8WJpLT/9RA16YUFKXTNUFDMjTc/VQKTQ=="], + + "@douyinfe/semi-json-viewer-core": ["@douyinfe/semi-json-viewer-core@2.72.2", "", { "dependencies": { "jsonc-parser": "^3.3.1" } }, "sha512-h87OKEgvWAzqu9XBmc1Y0v6+QiETFFZx5wJbEqMSXuPx3EpW8Q6oYiKKSyaaLNcVC6ytBB2Jf71GFvM8HpcuSg=="], + + "@douyinfe/semi-theme-default": ["@douyinfe/semi-theme-default@2.72.2", "", {}, "sha512-XaMXl9hPtgNF0h8SIptJQSUzQdk78wB5AlAAnOIY0i+27S/3CT5WGBCj27wZqgz/FZcT8xK1sG29B0oXdRhgew=="], + + "@douyinfe/semi-ui": ["@douyinfe/semi-ui@2.72.2", "", { "dependencies": { "@dnd-kit/core": "^6.0.8", "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.1", "@douyinfe/semi-animation": "2.72.2", "@douyinfe/semi-animation-react": "2.72.2", "@douyinfe/semi-foundation": "2.72.2", "@douyinfe/semi-icons": "2.72.2", "@douyinfe/semi-illustrations": "2.72.2", "@douyinfe/semi-theme-default": "2.72.2", "async-validator": "^3.5.0", "classnames": "^2.2.6", "copy-text-to-clipboard": "^2.1.1", "date-fns": "^2.29.3", "date-fns-tz": "^1.3.8", "fast-copy": "^3.0.1 ", "jsonc-parser": "^3.3.1", "lodash": "^4.17.21", "prop-types": "^15.7.2", "react-resizable": "^3.0.5", "react-window": "^1.8.2", "scroll-into-view-if-needed": "^2.2.24", "utility-types": "^3.10.0" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-okV/9mwSjsGEw5iBOGt2Z/cNIp/VQmgFjQRgwzagoXPmXU5NVSUyxYLdgQXUZXa6po4ZmVZB5IZpyddGGUeDiA=="], + + "@douyinfe/vite-plugin-semi": ["@douyinfe/vite-plugin-semi@2.74.0-alpha.6", "", { "dependencies": { "sass": "^1.85.1" }, "peerDependencies": { "vite": "5.1.0" } }, "sha512-juyKSG0onVBG29FLdGPBA0yHT9Kh7P8e0FDtwhp0DuMk6drd45bDQZuU171gzx0ahv9rJaojnD6CgcBiggtQ3A=="], + + "@emoji-mart/data": ["@emoji-mart/data@1.2.1", "", {}, "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="], + + "@emoji-mart/react": ["@emoji-mart/react@1.1.1", "", { "peerDependencies": { "emoji-mart": "^5.2", "react": "^16.8 || ^17 || ^18" } }, "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g=="], + + "@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="], + + "@emotion/cache": ["@emotion/cache@11.14.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="], + + "@emotion/css": ["@emotion/css@11.13.5", "", { "dependencies": { "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.13.5", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2" } }, "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w=="], + + "@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="], + + "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], + + "@emotion/react": ["@emotion/react@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="], + + "@emotion/serialize": ["@emotion/serialize@1.3.3", "", { "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="], + + "@emotion/sheet": ["@emotion/sheet@1.4.0", "", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="], + + "@emotion/unitless": ["@emotion/unitless@0.7.5", "", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="], + + "@emotion/use-insertion-effect-with-fallbacks": ["@emotion/use-insertion-effect-with-fallbacks@1.2.0", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="], + + "@emotion/utils": ["@emotion/utils@1.4.2", "", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="], + + "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="], + + "@eslint/js": ["@eslint/js@8.57.0", "", {}, "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg=="], + + "@floating-ui/react": ["@floating-ui/react@0.27.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.2", "@floating-ui/utils": "^0.2.9", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-EQJ4Th328y2wyHR3KzOUOoTW2UKjFk53fmyahfwExnFQ8vnsMYqKc+fFPOkeYtj5tcp1DUMiNJ7BFhed7e9ONw=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.2", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="], + + "@giscus/react": ["@giscus/react@3.1.0", "", { "dependencies": { "giscus": "^1.6.0" }, "peerDependencies": { "react": "^16 || ^17 || ^18 || ^19", "react-dom": "^16 || ^17 || ^18 || ^19" } }, "sha512-0TCO2TvL43+oOdyVVGHDItwxD1UMKP2ZYpT6gXmhFOqfAJtZxTzJ9hkn34iAF/b6YzyJ4Um89QIt9z/ajmAEeg=="], + + "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.11.14", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.2", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], + + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@iconify/utils": ["@iconify/utils@2.3.0", "", { "dependencies": { "@antfu/install-pkg": "^1.0.0", "@antfu/utils": "^8.1.0", "@iconify/types": "^2.0.0", "debug": "^4.4.0", "globals": "^15.14.0", "kolorist": "^1.8.0", "local-pkg": "^1.0.0", "mlly": "^1.7.4" } }, "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA=="], + + "@inquirer/ansi": ["@inquirer/ansi@1.0.1", "", {}, "sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw=="], + + "@inquirer/checkbox": ["@inquirer/checkbox@4.3.0", "", { "dependencies": { "@inquirer/ansi": "^1.0.1", "@inquirer/core": "^10.3.0", "@inquirer/figures": "^1.0.14", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5+Q3PKH35YsnoPTh75LucALdAxom6xh5D1oeY561x4cqBuH24ZFVyFREPe14xgnrtmGu3EEt1dIi60wRVSnGCw=="], + + "@inquirer/confirm": ["@inquirer/confirm@5.1.19", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ=="], + + "@inquirer/core": ["@inquirer/core@10.3.0", "", { "dependencies": { "@inquirer/ansi": "^1.0.1", "@inquirer/figures": "^1.0.14", "@inquirer/type": "^3.0.9", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA=="], + + "@inquirer/editor": ["@inquirer/editor@4.2.21", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/external-editor": "^1.0.2", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-MjtjOGjr0Kh4BciaFShYpZ1s9400idOdvQ5D7u7lE6VztPFoyLcVNE5dXBmEEIQq5zi4B9h2kU+q7AVBxJMAkQ=="], + + "@inquirer/expand": ["@inquirer/expand@4.0.21", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-+mScLhIcbPFmuvU3tAGBed78XvYHSvCl6dBiYMlzCLhpr0bzGzd8tfivMMeqND6XZiaZ1tgusbUHJEfc6YzOdA=="], + + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.2", "", { "dependencies": { "chardet": "^2.1.0", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ=="], + + "@inquirer/figures": ["@inquirer/figures@1.0.14", "", {}, "sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ=="], + + "@inquirer/input": ["@inquirer/input@4.2.5", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-7GoWev7P6s7t0oJbenH0eQ0ThNdDJbEAEtVt9vsrYZ9FulIokvd823yLyhQlWHJPGce1wzP53ttfdCZmonMHyA=="], + + "@inquirer/number": ["@inquirer/number@3.0.21", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5QWs0KGaNMlhbdhOSCFfKsW+/dcAVC2g4wT/z2MCiZM47uLgatC5N20kpkDQf7dHx+XFct/MJvvNGy6aYJn4Pw=="], + + "@inquirer/password": ["@inquirer/password@4.0.21", "", { "dependencies": { "@inquirer/ansi": "^1.0.1", "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-xxeW1V5SbNFNig2pLfetsDb0svWlKuhmr7MPJZMYuDnCTkpVBI+X/doudg4pznc1/U+yYmWFFOi4hNvGgUo7EA=="], + + "@inquirer/prompts": ["@inquirer/prompts@7.9.0", "", { "dependencies": { "@inquirer/checkbox": "^4.3.0", "@inquirer/confirm": "^5.1.19", "@inquirer/editor": "^4.2.21", "@inquirer/expand": "^4.0.21", "@inquirer/input": "^4.2.5", "@inquirer/number": "^3.0.21", "@inquirer/password": "^4.0.21", "@inquirer/rawlist": "^4.1.9", "@inquirer/search": "^3.2.0", "@inquirer/select": "^4.4.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@4.1.9", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-AWpxB7MuJrRiSfTKGJ7Y68imYt8P9N3Gaa7ySdkFj1iWjr6WfbGAhdZvw/UnhFXTHITJzxGUI9k8IX7akAEBCg=="], + + "@inquirer/search": ["@inquirer/search@3.2.0", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/figures": "^1.0.14", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-a5SzB/qrXafDX1Z4AZW3CsVoiNxcIYCzYP7r9RzrfMpaLpB+yWi5U8BWagZyLmwR0pKbbL5umnGRd0RzGVI8bQ=="], + + "@inquirer/select": ["@inquirer/select@4.4.0", "", { "dependencies": { "@inquirer/ansi": "^1.0.1", "@inquirer/core": "^10.3.0", "@inquirer/figures": "^1.0.14", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-kaC3FHsJZvVyIjYBs5Ih8y8Bj4P/QItQWrZW22WJax7zTN+ZPXVGuOM55vzbdCP9zKUiBd9iEJVdesujfF+cAA=="], + + "@inquirer/type": ["@inquirer/type@3.0.9", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w=="], + + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], + + "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + + "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.3.0", "", {}, "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ=="], + + "@lit/reactive-element": ["@lit/reactive-element@2.1.0", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.2.0" } }, "sha512-L2qyoZSQClcBmq0qajBVbhYEcG6iK0XfLn66ifLe/RfC0/ihpc+pl0Wdn8bJ8o+hj38cG0fGXRgSS20MuXn7qA=="], + + "@lobehub/emojilib": ["@lobehub/emojilib@1.0.0", "", {}, "sha512-s9KnjaPjsEefaNv150G3aifvB+J3P4eEKG+epY9zDPS2BeB6+V2jELWqAZll+nkogMaVovjEE813z3V751QwGw=="], + + "@lobehub/fluent-emoji": ["@lobehub/fluent-emoji@2.0.0", "", { "dependencies": { "@lobehub/emojilib": "^1.0.0", "@lobehub/ui": "^2.0.0", "antd-style": "^3.7.1", "emoji-regex": "^10.4.0", "lodash-es": "^4.17.21", "lucide-react": "^0.469.0", "react-layout-kit": "^1.9.1", "url-join": "^5.0.0" }, "peerDependencies": { "antd": "^5.23.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-bKjU3sf0+7NppvcdqD/raWvKGJIw8HDJVporNQ7oR8pIPoLeb9IUu/vqIYClOlwfu9qntji7FFySfbdNqXSiJw=="], + + "@lobehub/icons": ["@lobehub/icons@2.1.0", "", { "dependencies": { "@lobehub/ui": "^2.0.0", "antd-style": "^3.7.1", "lucide-react": "^0.469.0", "polished": "^4.3.1", "react-layout-kit": "^1.9.1" }, "peerDependencies": { "antd": "^5.23.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-iHtIp8a05/YHxTDlOFXCTfvYXUjKi1Mbq5a9qsEN+zwJ5U+mR2WgKz5zUausIzZiMZo+P3pgxbhh3/eHf7Q1pw=="], + + "@lobehub/ui": ["@lobehub/ui@2.1.10", "", { "dependencies": { "@ant-design/cssinjs": "^1.23.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@floating-ui/react": "^0.27.5", "@giscus/react": "^3.1.0", "@lobehub/fluent-emoji": "^2.0.0", "@lobehub/icons": "^2.0.0", "@mdx-js/mdx": "^3.1.0", "@mdx-js/react": "^3.1.0", "@radix-ui/react-slot": "^1.1.2", "@shikijs/transformers": "^3.2.1", "@splinetool/runtime": "0.9.526", "ahooks": "^3.8.4", "antd-style": "^3.7.1", "chroma-js": "^3.1.2", "class-variance-authority": "^0.7.1", "dayjs": "^1.11.13", "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", "framer-motion": "^12.6.3", "immer": "^10.1.1", "katex": "^0.16.9", "leva": "^0.10.0", "lodash-es": "^4.17.21", "lucide-react": "^0.484.0", "mermaid": "^11.6.0", "numeral": "^2.0.6", "polished": "^4.3.1", "query-string": "^9.1.1", "rc-collapse": "^4.0.0", "rc-footer": "^0.6.8", "rc-image": "^7.11.1", "rc-menu": "^9.16.1", "re-resizable": "^6.11.2", "react-avatar-editor": "^13.0.2", "react-error-boundary": "^5.0.0", "react-hotkeys-hook": "^5.1.0", "react-layout-kit": "^1.9.1", "react-markdown": "^10.1.0", "react-merge-refs": "^3.0.2", "react-rnd": "^10.5.2", "react-zoom-pan-pinch": "^3.7.0", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "shiki": "^3.2.1", "swr": "^2.3.3", "ts-md5": "^1.3.1", "unified": "^11.0.5", "url-join": "^5.0.0", "use-merge-value": "^1.2.0", "uuid": "^11.1.0" }, "peerDependencies": { "antd": "^5.25.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-R1/t5I8UAjvd5xoEDJXg6RzHmwhdOU45JQN297MlYB/sGqcvySfQL9POpDmySSs+QMyjkhwhum254cfXFKJIZA=="], + + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw=="], + + "@mdx-js/react": ["@mdx-js/react@3.1.0", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ=="], + + "@mermaid-js/parser": ["@mermaid-js/parser@0.4.0", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-primitive": "1.0.2" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-fqYwhhI9IarZ0ll2cUSfKuXHlJK0qE4AfnRrPBbRwEH/4mGQn04/QFGomLi8TXWIdv9WJk//KgGm+aDxVIr1wA=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.0.3", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/primitive": "1.0.0", "@radix-ui/react-compose-refs": "1.0.0", "@radix-ui/react-primitive": "1.0.2", "@radix-ui/react-use-callback-ref": "1.0.0", "@radix-ui/react-use-escape-keydown": "1.0.2" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-nXZOvFjOuHS1ovumntGV7NNoLaEp9JEvTht3MBjP44NSW5hUKj/8OnfN3+8WmB+CEhN44XaGhpHoSsUIEl5P7Q=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-layout-effect": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.1.1", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@floating-ui/react-dom": "0.7.2", "@radix-ui/react-arrow": "1.0.2", "@radix-ui/react-compose-refs": "1.0.0", "@radix-ui/react-context": "1.0.0", "@radix-ui/react-primitive": "1.0.2", "@radix-ui/react-use-callback-ref": "1.0.0", "@radix-ui/react-use-layout-effect": "1.0.0", "@radix-ui/react-use-rect": "1.0.0", "@radix-ui/react-use-size": "1.0.0", "@radix-ui/rect": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-keYDcdMPNMjSC8zTsZ8wezUMiWM9Yj14wtF3s0PTIs9srnEPC9Kt2Gny1T3T81mmSeyDjZxsD9N5WCwNNb712w=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-primitive": "1.0.2" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-swu32idoCW7KA2VEiUZGBSu9nB6qwGdV6k6HYhUoOo3M1FFpD+VgLzUqtt3mwL1ssz7r2x8MggpLSQach2Xy/Q=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.0", "@radix-ui/react-use-layout-effect": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-slot": "1.0.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.0.5", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/primitive": "1.0.0", "@radix-ui/react-compose-refs": "1.0.0", "@radix-ui/react-context": "1.0.0", "@radix-ui/react-dismissable-layer": "1.0.3", "@radix-ui/react-id": "1.0.0", "@radix-ui/react-popper": "1.1.1", "@radix-ui/react-portal": "1.0.2", "@radix-ui/react-presence": "1.0.0", "@radix-ui/react-primitive": "1.0.2", "@radix-ui/react-slot": "1.0.1", "@radix-ui/react-use-controllable-state": "1.0.0", "@radix-ui/react-visually-hidden": "1.0.2" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-cDKVcfzyO6PpckZekODJZDe5ZxZ2fCZlzKzTmPhe4mX9qTHRfLcKgqb0OKf22xLwDequ2tVleim+ZYx3rabD5w=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-DXGim3x74WgUv+iMNCF+cAo8xUHHeqvjx8zs7trKf+FkQKPQXLk2sX7Gx1ysH7Q76xCpZuxIJE7HLPxRE+Q+GA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/rect": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-TB7pID8NRMEHxb/qQJpvSt3hQU4sqNPM1VCTjTRjEOa7cEop/QMuq8S6fb/5Tsz64kqSvB9WnwsDHtjnrM9qew=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-layout-effect": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-imZ3aYcoYCKhhgNpkNDh/aTiU05qw9hX+HHI1QDBTyIlcFjgeFlKKySNGMwTp7nYFLQg/j0VA2FmCY4WPDDHMg=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-primitive": "1.0.2" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-qirnJxtYn73HEk1rXL12/mXnu2rwsNHDID10th2JGtdK25T9wX+mxRmGt7iPSahw512GbZOc0syZX1nLQGoEOg=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-d0O68AYy/9oeEy1DdC07bz1/ZXX+DqCskRd3i4JzLSTXwefzaepQrKjXC7aNM8lTHjFLDO0pDgaEiQ7jEk+HVg=="], + + "@rc-component/async-validator": ["@rc-component/async-validator@5.0.4", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg=="], + + "@rc-component/color-picker": ["@rc-component/color-picker@2.0.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6", "@babel/runtime": "^7.23.6", "classnames": "^2.2.6", "rc-util": "^5.38.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q=="], + + "@rc-component/context": ["@rc-component/context@1.4.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w=="], + + "@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ=="], + + "@rc-component/mutate-observer": ["@rc-component/mutate-observer@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.18.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw=="], + + "@rc-component/portal": ["@rc-component/portal@1.1.2", "", { "dependencies": { "@babel/runtime": "^7.18.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg=="], + + "@rc-component/qrcode": ["@rc-component/qrcode@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.7", "classnames": "^2.3.2", "rc-util": "^5.38.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-L+rZ4HXP2sJ1gHMGHjsg9jlYBX/SLN2D6OxP9Zn3qgtpMWtO2vUfxVFwiogHpAIqs54FnALxraUy/BCO1yRIgg=="], + + "@rc-component/tour": ["@rc-component/tour@1.15.1", "", { "dependencies": { "@babel/runtime": "^7.18.0", "@rc-component/portal": "^1.0.0-9", "@rc-component/trigger": "^2.0.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ=="], + + "@rc-component/trigger": ["@rc-component/trigger@2.2.6", "", { "dependencies": { "@babel/runtime": "^7.23.2", "@rc-component/portal": "^1.1.0", "classnames": "^2.3.2", "rc-motion": "^2.0.0", "rc-resize-observer": "^1.3.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-/9zuTnWwhQ3S3WT1T8BubuFTT46kvnXgaERR9f4BTKyn61/wpf/BvbImzYBubzJibU707FxwbKszLlHjcLiv1Q=="], + + "@remix-run/router": ["@remix-run/router@1.21.0", "", {}, "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA=="], + + "@resvg/resvg-js": ["@resvg/resvg-js@2.4.1", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.4.1", "@resvg/resvg-js-android-arm64": "2.4.1", "@resvg/resvg-js-darwin-arm64": "2.4.1", "@resvg/resvg-js-darwin-x64": "2.4.1", "@resvg/resvg-js-linux-arm-gnueabihf": "2.4.1", "@resvg/resvg-js-linux-arm64-gnu": "2.4.1", "@resvg/resvg-js-linux-arm64-musl": "2.4.1", "@resvg/resvg-js-linux-x64-gnu": "2.4.1", "@resvg/resvg-js-linux-x64-musl": "2.4.1", "@resvg/resvg-js-win32-arm64-msvc": "2.4.1", "@resvg/resvg-js-win32-ia32-msvc": "2.4.1", "@resvg/resvg-js-win32-x64-msvc": "2.4.1" } }, "sha512-wTOf1zerZX8qYcMmLZw3czR4paI4hXqPjShNwJRh5DeHxvgffUS5KM7XwxtbIheUW6LVYT5fhT2AJiP6mU7U4A=="], + + "@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.4.1", "", { "os": "android", "cpu": "arm" }, "sha512-AA6f7hS0FAPpvQMhBCf6f1oD1LdlqNXKCxAAPpKh6tR11kqV0YIB9zOlIYgITM14mq2YooLFl6XIbbvmY+jwUw=="], + + "@resvg/resvg-js-android-arm64": ["@resvg/resvg-js-android-arm64@2.4.1", "", { "os": "android", "cpu": "arm64" }, "sha512-/QleoRdPfsEuH9jUjilYcDtKK/BkmWcK+1LXM8L2nsnf/CI8EnFyv7ZzCj4xAIvZGAy9dTYr/5NZBcTwxG2HQg=="], + + "@resvg/resvg-js-darwin-arm64": ["@resvg/resvg-js-darwin-arm64@2.4.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-U1oMNhea+kAXgiEXgzo7EbFGCD1Edq5aSlQoe6LMly6UjHzgx2W3N5kEXCwU/CgN5FiQhZr7PlSJSlcr7mdhfg=="], + + "@resvg/resvg-js-darwin-x64": ["@resvg/resvg-js-darwin-x64@2.4.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-avyVh6DpebBfHHtTQTZYSr6NG1Ur6TEilk1+H0n7V+g4F7x7WPOo8zL00ZhQCeRQ5H4f8WXNWIEKL8fwqcOkYw=="], + + "@resvg/resvg-js-linux-arm-gnueabihf": ["@resvg/resvg-js-linux-arm-gnueabihf@2.4.1", "", { "os": "linux", "cpu": "arm" }, "sha512-isY/mdKoBWH4VB5v621co+8l101jxxYjuTkwOLsbW+5RK9EbLciPlCB02M99ThAHzI2MYxIUjXNmNgOW8btXvw=="], + + "@resvg/resvg-js-linux-arm64-gnu": ["@resvg/resvg-js-linux-arm64-gnu@2.4.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uY5voSCrFI8TH95vIYBm5blpkOtltLxLRODyhKJhGfskOI7XkRw5/t1u0sWAGYD8rRSNX+CA+np86otKjubrNg=="], + + "@resvg/resvg-js-linux-arm64-musl": ["@resvg/resvg-js-linux-arm64-musl@2.4.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-6mT0+JBCsermKMdi/O2mMk3m7SqOjwi9TKAwSngRZ/nQoL3Z0Z5zV+572ztgbWr0GODB422uD8e9R9zzz38dRQ=="], + + "@resvg/resvg-js-linux-x64-gnu": ["@resvg/resvg-js-linux-x64-gnu@2.4.1", "", { "os": "linux", "cpu": "x64" }, "sha512-60KnrscLj6VGhkYOJEmmzPlqqfcw1keDh6U+vMcNDjPhV3B5vRSkpP/D/a8sfokyeh4VEacPSYkWGezvzS2/mg=="], + + "@resvg/resvg-js-linux-x64-musl": ["@resvg/resvg-js-linux-x64-musl@2.4.1", "", { "os": "linux", "cpu": "x64" }, "sha512-0AMyZSICC1D7ge115cOZQW8Pcad6PjWuZkBFF3FJuSxC6Dgok0MQnLTs2MfMdKBlAcwO9dXsf3bv9tJZj8pATA=="], + + "@resvg/resvg-js-win32-arm64-msvc": ["@resvg/resvg-js-win32-arm64-msvc@2.4.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-76XDFOFSa3d0QotmcNyChh2xHwk+JTFiEQBVxMlHpHMeq7hNrQJ1IpE1zcHSQvrckvkdfLboKRrlGB86B10Qjw=="], + + "@resvg/resvg-js-win32-ia32-msvc": ["@resvg/resvg-js-win32-ia32-msvc@2.4.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-odyVFGrEWZIzzJ89KdaFtiYWaIJh9hJRW/frcEcG3agJ464VXkN/2oEVF5ulD+5mpGlug9qJg7htzHcKxDN8sg=="], + + "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.4.1", "", { "os": "win32", "cpu": "x64" }, "sha512-vY4kTLH2S3bP+puU5x7hlAxHv+ulFgcK6Zn3efKSr0M0KnZ9A3qeAjZteIpkowEFfUeMPNg2dvvoFRJA9zqxSw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.30.0", "", { "os": "android", "cpu": "arm" }, "sha512-qFcFto9figFLz2g25DxJ1WWL9+c91fTxnGuwhToCl8BaqDsDYMl/kOnBXAyAqkkzAWimYMSWNPWEjt+ADAHuoQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.30.0", "", { "os": "android", "cpu": "arm64" }, "sha512-vqrQdusvVl7dthqNjWCL043qelBK+gv9v3ZiqdxgaJvmZyIAAXMjeGVSqZynKq69T7062T5VrVTuikKSAAVP6A=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.30.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-617pd92LhdA9+wpixnzsyhVft3szYiN16aNUMzVkf2N+yAk8UXY226Bfp36LvxYTUt7MO/ycqGFjQgJ0wlMaWQ=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.30.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y3b4oDoaEhCypg8ajPqigKDcpi5ZZovemQl9Edpem0uNv6UUjXv7iySBpGIUTSs2ovWOzYpfw9EbFJXF/fJHWw=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.30.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3REQJ4f90sFIBfa0BUokiCdrV/E4uIjhkWe1bMgCkhFXbf4D8YN6C4zwJL881GM818qVYE9BO3dGwjKhpo2ABA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.30.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZtY3Y8icbe3Cc+uQicsXG5L+CRGUfLZjW6j2gn5ikpltt3Whqjfo5mkyZ86UiuHF9Q3ZsaQeW7YswlHnN+lAcg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.30.0", "", { "os": "linux", "cpu": "arm" }, "sha512-bsPGGzfiHXMhQGuFGpmo2PyTwcrh2otL6ycSZAFTESviUoBOuxF7iBbAL5IJXc/69peXl5rAtbewBFeASZ9O0g=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.30.0", "", { "os": "linux", "cpu": "arm" }, "sha512-kvyIECEhs2DrrdfQf++maCWJIQ974EI4txlz1nNSBaCdtf7i5Xf1AQCEJWOC5rEBisdaMFFnOWNLYt7KpFqy5A=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.30.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-CFE7zDNrokaotXu+shwIrmWrFxllg79vciH4E/zeK7NitVuWEaXRzS0mFfFvyhZfn8WfVOG/1E9u8/DFEgK7WQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.30.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-MctNTBlvMcIBP0t8lV/NXiUwFg9oK5F79CxLU+a3xgrdJjfBLVIEHSAjQ9+ipofN2GKaMLnFFXLltg1HEEPaGQ=="], + + "@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.30.0", "", { "os": "linux", "cpu": "none" }, "sha512-fBpoYwLEPivL3q368+gwn4qnYnr7GVwM6NnMo8rJ4wb0p/Y5lg88vQRRP077gf+tc25akuqd+1Sxbn9meODhwA=="], + + "@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.30.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hiHPV6dUaqIMXrIjN+vgJqtfkLpqHS1Xsg0oUfUVD98xGp1wX89PIXgDF2DWra1nxAd8dfE0Dk59MyeKaBVAw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.30.0", "", { "os": "linux", "cpu": "none" }, "sha512-U0xcC80SMpEbvvLw92emHrNjlS3OXjAM0aVzlWfar6PR0ODWCTQtKeeB+tlAPGfZQXicv1SpWwRz9Hyzq3Jx3g=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.30.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-VU/P/IODrNPasgZDLIFJmMiLGez+BN11DQWfTVlViJVabyF3JaeaJkP6teI8760f18BMGCQOW9gOmuzFaI1pUw=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.30.0", "", { "os": "linux", "cpu": "x64" }, "sha512-laQVRvdbKmjXuFA3ZiZj7+U24FcmoPlXEi2OyLfbpY2MW1oxLt9Au8q9eHd0x6Pw/Kw4oe9gwVXWwIf2PVqblg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.30.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3wzKzduS7jzxqcOvy/ocU/gMR3/QrHEFLge5CD7Si9fyHuoXcidyYZ6jyx8OPYmCcGm3uKTUl+9jUSAY74Ln5A=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.30.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jROwnI1+wPyuv696rAFHp5+6RFhXGGwgmgSfzE8e4xfit6oLRg7GyMArVUoM3ChS045OwWr9aTnU+2c1UdBMyw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.30.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-duzweyup5WELhcXx5H1jokpr13i3BV9b48FMiikYAwk/MT1LrMYYk2TzenBd0jj4ivQIt58JWSxc19y4SvLP4g=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.30.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DYvxS0M07PvgvavMIybCOBYheyrqlui6ZQBHJs6GqduVzHSZ06TPPvlfvnYstjODHQ8UUXFwt5YE+h0jFI8kwg=="], + + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@shikijs/core": ["@shikijs/core@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AG8vnSi1W2pbgR2B911EfGqtLE9c4hQBYkv/x7Z+Kt0VxhgQKcW7UNDVYsu9YxwV6u+OJrvdJrMq6DNWoBjihQ=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-1/adJbSMBOkpScCE/SB6XkjJU17ANln3Wky7lOmrnpl+zBdQ1qXUJg2GXTYVHRq+2j3hd1DesmElTXYDgtfSOQ=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-zcZKMnNndgRa3ORja6Iemsr3DrLtkX3cAF7lTJkdMB6v9alhlBsX9uNiCpqofNrXOvpA3h6lHcLJxgCIhVOU5Q=="], + + "@shikijs/langs": ["@shikijs/langs@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2" } }, "sha512-H6azIAM+OXD98yztIfs/KH5H4PU39t+SREhmM8LaNXyUrqj2mx+zVkr8MWYqjceSjDw9I1jawm1WdFqU806rMA=="], + + "@shikijs/themes": ["@shikijs/themes@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2" } }, "sha512-qAEuAQh+brd8Jyej2UDDf+b4V2g1Rm8aBIdvt32XhDPrHvDkEnpb7Kzc9hSuHUxz0Iuflmq7elaDuQAP9bHIhg=="], + + "@shikijs/transformers": ["@shikijs/transformers@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/types": "3.4.2" } }, "sha512-I5baLVi/ynLEOZoWSAMlACHNnG+yw5HDmse0oe+GW6U1u+ULdEB3UHiVWaHoJSSONV7tlcVxuaMy74sREDkSvg=="], + + "@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + + "@so1ve/prettier-config": ["@so1ve/prettier-config@3.1.0", "", { "dependencies": { "@so1ve/prettier-plugin-toml": "3.1.0", "prettier-plugin-astro": "^0.14.0", "prettier-plugin-curly-and-jsdoc": "3.1.0", "prettier-plugin-pkgsort": "^0.2.1" }, "peerDependencies": { "prettier": "^3.0.0" } }, "sha512-9GJ1yXKBC4DzqCTTaZoBf8zw7WWkVuXcccZt1Aqk4lj6ab/GiNUnjPGajUVYLjaqAEOKqM7jUSUfTjk2JTjCAg=="], + + "@so1ve/prettier-plugin-toml": ["@so1ve/prettier-plugin-toml@3.1.0", "", { "peerDependencies": { "prettier": "^3.0.0" } }, "sha512-8WZAGjAVNIJlkfWL6wHKxlUuEBY45fdd5qY5bR/Z6r/txgzKXk/r9qi1DTwc17gi/WcNuRrcRugecRT+mWbIYg=="], + + "@splinetool/runtime": ["@splinetool/runtime@0.9.526", "", { "dependencies": { "on-change": "^4.0.0", "semver-compare": "^1.0.0" } }, "sha512-qznHbXA5aKwDbCgESAothCNm1IeEZcmNWG145p5aXj4w5uoqR1TZ9qkTHTKLTsUbHeitCwdhzmRqan1kxboLgQ=="], + + "@stitches/react": ["@stitches/react@1.2.8", "", { "peerDependencies": { "react": ">= 16.3.0" } }, "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA=="], + + "@swc/core": ["@swc/core@1.13.19", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.13.19", "@swc/core-darwin-x64": "1.13.19", "@swc/core-linux-arm-gnueabihf": "1.13.19", "@swc/core-linux-arm64-gnu": "1.13.19", "@swc/core-linux-arm64-musl": "1.13.19", "@swc/core-linux-x64-gnu": "1.13.19", "@swc/core-linux-x64-musl": "1.13.19", "@swc/core-win32-arm64-msvc": "1.13.19", "@swc/core-win32-ia32-msvc": "1.13.19", "@swc/core-win32-x64-msvc": "1.13.19" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-V1r4wFdjaZIUIZZrV2Mb/prEeu03xvSm6oatPxsvnXKF9lNh5Jtk9QvUdiVfD9rrvi7bXrAVhg9Wpbmv/2Fl1g=="], + + "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.13.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NxDyte9tCJSJ8+R62WDtqwg8eI57lubD52sHyGOfezpJBOPr36bUSGGLyO3Vod9zTGlOu2CpkuzA/2iVw92u1g=="], + + "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.13.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-+w5DYrJndSygFFRDcuPYmx5BljD6oYnAohZ15K1L6SfORHp/BTSIbgSFRKPoyhjuIkDiq3W0um8RoMTOBAcQjQ=="], + + "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.13.19", "", { "os": "linux", "cpu": "arm" }, "sha512-7LlfgpdwwYq2q7himNkAAFo4q6jysMLFNoBH6GRP7WL29NcSsl5mPMJjmYZymK+sYq/9MTVieDTQvChzYDsapw=="], + + "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.13.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-ml3I6Lm2marAQ3UC/TS9t/yILBh/eDSVHAdPpikp652xouWAVW1znUeV6bBSxe1sSZIenv+p55ubKAWq/u84sQ=="], + + "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.13.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-M/otFc3/rWWkbF6VgbOXVzUKVoE7MFcphTaStxJp4bwb7oP5slYlxMZN51Dk/OTOfvCDo9pTAFDKNyixbkXMDQ=="], + + "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.13.19", "", { "os": "linux", "cpu": "x64" }, "sha512-NoMUKaOJEdouU4tKF88ggdDHFiRRING+gYLxDqnTfm+sUXaizB5OGBRzvSVDYSXQb1SuUuChnXFPFzwTWbt3ZQ=="], + + "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.13.19", "", { "os": "linux", "cpu": "x64" }, "sha512-r6krlZwyu8SBaw24QuS1lau2I9q8M+eJV6ITz0rpb6P1Bx0elf9ii5Bhh8ddmIqXXH8kOGSjC/dwcdHbZqAhgw=="], + + "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.13.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-awcZSIuxyVn0Dw28VjMvgk1qiDJ6CeQwHkZNUjg2UxVlq23zE01NMMp+zkoGFypmLG9gaGmJSzuoqvk/WCQ5tw=="], + + "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.13.19", "", { "os": "win32", "cpu": "ia32" }, "sha512-H5d+KO7ISoLNgYvTbOcCQjJZNM3R7yaYlrMAF13lUr6GSiOUX+92xtM31B+HvzAWI7HtvVe74d29aC1b1TpXFA=="], + + "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.13.19", "", { "os": "win32", "cpu": "x64" }, "sha512-qNoyCpXvv2O3JqXKanRIeoMn03Fho/As+N4Fhe7u0FsYh4VYqGQah4DGDzEP/yjl4Gx1IElhqLGDhCCGMwWaDw=="], + + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + + "@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="], + + "@turf/boolean-clockwise": ["@turf/boolean-clockwise@6.5.0", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" } }, "sha512-45+C7LC5RMbRWrxh3Z0Eihsc8db1VGBO5d9BLTOAwU4jR6SgsunTfRWR16X7JUwIDYlCVEmnjcXJNi/kIU3VIw=="], + + "@turf/clone": ["@turf/clone@6.5.0", "", { "dependencies": { "@turf/helpers": "^6.5.0" } }, "sha512-mzVtTFj/QycXOn6ig+annKrM6ZlimreKYz6f/GSERytOpgzodbQyOgkfwru100O1KQhhjSudKK4DsQ0oyi9cTw=="], + + "@turf/flatten": ["@turf/flatten@6.5.0", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0" } }, "sha512-IBZVwoNLVNT6U/bcUUllubgElzpMsNoCw8tLqBw6dfYg9ObGmpEjf9BIYLr7a2Yn5ZR4l7YIj2T7kD5uJjZADQ=="], + + "@turf/helpers": ["@turf/helpers@6.5.0", "", {}, "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw=="], + + "@turf/invariant": ["@turf/invariant@6.5.0", "", { "dependencies": { "@turf/helpers": "^6.5.0" } }, "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg=="], + + "@turf/meta": ["@turf/meta@6.5.0", "", { "dependencies": { "@turf/helpers": "^6.5.0" } }, "sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA=="], + + "@turf/rewind": ["@turf/rewind@6.5.0", "", { "dependencies": { "@turf/boolean-clockwise": "^6.5.0", "@turf/clone": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "@turf/meta": "^6.5.0" } }, "sha512-IoUAMcHWotBWYwSYuYypw/LlqZmO+wcBpn8ysrBNbazkFNkLf3btSDZMkKJO/bvOzl55imr/Xj4fi3DdsLsbzQ=="], + + "@types/acorn": ["@types/acorn@4.0.6", "", { "dependencies": { "@types/estree": "*" } }, "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.6.8", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], + + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + + "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], + + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.6", "", {}, "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + + "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], + + "@types/ms": ["@types/ms@0.7.34", "", {}, "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g=="], + + "@types/parse-author": ["@types/parse-author@2.0.3", "", {}, "sha512-pgRW2K/GVQoogylrGJXDl7PBLW9A6T4OOc9Hy9MLT5f7vgufK2GQ8FcfAbjFHR5HjcN9ByzuCczAORk49REqoA=="], + + "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], + + "@types/react": ["@types/react@19.1.5", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.2.1", "", {}, "sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA=="], + + "@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="], + + "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="], + + "@visactor/react-vchart": ["@visactor/react-vchart@1.8.11", "", { "dependencies": { "@visactor/vchart": "1.8.11", "@visactor/vgrammar-core": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vutils": "~0.17.3", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-wHnCex9gOpnttTtSu04ozKJhTveUk8Ln2KX/7PZyCJxqlXq+eWvW4zvM6Ja8T8kGXfXtFYVVNh9zBMQ7y2T/Sw=="], + + "@visactor/vchart": ["@visactor/vchart@1.8.11", "", { "dependencies": { "@visactor/vdataset": "~0.17.3", "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-hierarchy": "0.10.11", "@visactor/vgrammar-projection": "0.10.11", "@visactor/vgrammar-sankey": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vgrammar-wordcloud": "0.10.11", "@visactor/vgrammar-wordcloud-shape": "0.10.11", "@visactor/vrender-components": "0.17.17", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3", "@visactor/vutils-extension": "1.8.11" } }, "sha512-RdQ822J02GgAQNXvO1LiT0T3O6FjdgPdcm9hVBFyrpBBmuI8MH02IE7Y1kGe9NiFTH4tDwP0ixRgBmqNSGSLZQ=="], + + "@visactor/vchart-semi-theme": ["@visactor/vchart-semi-theme@1.8.8", "", { "dependencies": { "@visactor/vchart-theme-utils": "1.8.8" }, "peerDependencies": { "@visactor/vchart": "~1.8.8" } }, "sha512-lm57CX3r6Bm7iGBYYyWhDY+1BvkyhNVLEckKx2PnlPKpJHikKSIK2ACyI5SmHuSOOdYzhY2QK6ZfYa2NShJ83w=="], + + "@visactor/vchart-theme-utils": ["@visactor/vchart-theme-utils@1.8.8", "", { "peerDependencies": { "@visactor/vchart": "~1.8.8" } }, "sha512-RdCey3/t0+82EYyFZvx210rgJJWti9rsgcL3ROZS7o9CtRW1CMj9u9LKLDNIcPLNcLNACFC0aoT03jpdD1BCpA=="], + + "@visactor/vdataset": ["@visactor/vdataset@0.17.5", "", { "dependencies": { "@turf/flatten": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/rewind": "^6.5.0", "@visactor/vutils": "0.17.5", "d3-dsv": "^2.0.0", "d3-geo": "^1.12.1", "d3-hexbin": "^0.2.2", "d3-hierarchy": "^3.1.1", "eventemitter3": "^4.0.7", "geobuf": "^3.0.1", "geojson-dissolve": "^3.1.0", "path-browserify": "^1.0.1", "pbf": "^3.2.1", "point-at-length": "^1.1.0", "simple-statistics": "^7.7.3", "simplify-geojson": "^1.0.4", "topojson-client": "^3.1.0" } }, "sha512-zVBdLWHWrhldGc8JDjSYF9lvpFT4ZEFQDB0b6yvfSiHzHKHiSco+rWmUFvA7r4ObT6j2QWF1vZAV9To8Ml4vHw=="], + + "@visactor/vgrammar-coordinate": ["@visactor/vgrammar-coordinate@0.10.11", "", { "dependencies": { "@visactor/vgrammar-util": "0.10.11", "@visactor/vutils": "~0.17.3" } }, "sha512-XSUvEkaf/NQHFafmTwqoIMZicp9fF3o6NB2FDpuWrK4DI1lTuip/0RkqrC+kBAjc5erjt0em0TiITyqXpp4G6w=="], + + "@visactor/vgrammar-core": ["@visactor/vgrammar-core@0.10.11", "", { "dependencies": { "@visactor/vdataset": "~0.17.3", "@visactor/vgrammar-coordinate": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-components": "0.17.17", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-VL9vcLPDg1LrHl7EOx0Ga9ATsoaChKIaCGzxjrPEjWiIS5VPU9Rs0jBKP+ch8BjamAoSuqL5mKd0L/RaUBqlaA=="], + + "@visactor/vgrammar-hierarchy": ["@visactor/vgrammar-hierarchy@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vutils": "~0.17.3" } }, "sha512-0r3k51pPlJHu63BduG3htsV/ul62aVcKJxFftRfvKkwGjm1KeHoOZEEAwIf78U2puio0BkLqVn2Ek2L4FYZaIg=="], + + "@visactor/vgrammar-projection": ["@visactor/vgrammar-projection@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vutils": "~0.17.3", "d3-geo": "^1.12.1" } }, "sha512-yEiKsxdfs5+g60wv5xZ1kyS/EDrAsUzAxCMpFFASVUYbQObHvW+elm+UPq2TBX6KZqAM0gsd1inzaLvfsCrLSg=="], + + "@visactor/vgrammar-sankey": ["@visactor/vgrammar-sankey@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vutils": "~0.17.3" } }, "sha512-BbJTPuyydsL/L5XtQv59Q82GgJeePY7Wleac798usx3GnDK0GAOrPsI3bubSsOESJ4pNk3V4HPGEQDG1vCPb4w=="], + + "@visactor/vgrammar-util": ["@visactor/vgrammar-util@0.10.11", "", { "dependencies": { "@visactor/vutils": "~0.17.3" } }, "sha512-cJZLmKZvN95Y+yGhX+28+UpZu3bhYYlXDlHJNvXHyonI76ZYgtceyon2b3lI6XIsUsBGcD4Uo777s949X5os3g=="], + + "@visactor/vgrammar-wordcloud": ["@visactor/vgrammar-wordcloud@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vutils": "~0.17.3" } }, "sha512-JWDqjGhr9JlYkKVBeEkiOqLQk7C1x1BtnsZ+E8oN541gzUqHwfS9qZyhwI3OyoSLewJlsSSPu1vXLKSQzLzKPA=="], + + "@visactor/vgrammar-wordcloud-shape": ["@visactor/vgrammar-wordcloud-shape@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-NsQOYJp+9WHnIApMvkcUOaajxIg5U/r6rD8LKnoXW/HqAN2TFYXcRR3Daqmk9rrpM5VztQimKOsA1yZWyzozrA=="], + + "@visactor/vrender-components": ["@visactor/vrender-components@0.17.17", "", { "dependencies": { "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-7gYFQrozvBkyGF7s/JHXdWDZnATzymxzug63CZd4EB7A0OXKatVDImXRePqwzlPD3QamF7QMVWn0CuIx3gQ2gA=="], + + "@visactor/vrender-core": ["@visactor/vrender-core@0.17.17", "", { "dependencies": { "@visactor/vutils": "~0.17.3", "color-convert": "2.0.1" } }, "sha512-pAZGaimunDAWOBdFhzPh0auH5ryxAHr+MVoz+QdASG+6RZXy8D02l8v2QYu4+e4uorxe/s2ZkdNDm81SlNkoHQ=="], + + "@visactor/vrender-kits": ["@visactor/vrender-kits@0.17.17", "", { "dependencies": { "@resvg/resvg-js": "2.4.1", "@visactor/vrender-core": "0.17.17", "@visactor/vutils": "~0.17.3", "roughjs": "4.5.2" } }, "sha512-noRP1hAHvPCv36nf2P6sZ930Tk+dJ8jpPWIUm1cFYmUNdcumgIS8Cug0RyeZ+saSqVt5FDTwIwifhOqupw5Zaw=="], + + "@visactor/vscale": ["@visactor/vscale@0.17.5", "", { "dependencies": { "@visactor/vutils": "0.17.5" } }, "sha512-2dkS1IlAJ/IdTp8JElbctOOv6lkHKBKPDm8KvwBo0NuGWQeYAebSeyN3QCdwKbj76gMlCub4zc+xWrS5YiA2zA=="], + + "@visactor/vutils": ["@visactor/vutils@0.17.5", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw=="], + + "@visactor/vutils-extension": ["@visactor/vutils-extension@1.8.11", "", { "dependencies": { "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-Hknzpy3+xh4sdL0iSn5N93BHiMJF4FdwSwhHYEibRpriZmWKG6wBxsJ0Bll4d7oS4f+svxt8Sg2vRYKzQEcIxQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.3.4", "", { "dependencies": { "@babel/core": "^7.26.0", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", "@types/babel__core": "^7.20.5", "react-refresh": "^0.14.2" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug=="], + + "abs-svg-path": ["abs-svg-path@0.1.1", "", {}, "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], + + "ahooks": ["ahooks@3.8.5", "", { "dependencies": { "@babel/runtime": "^7.21.0", "dayjs": "^1.9.1", "intersection-observer": "^0.12.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", "react-fast-compare": "^3.2.2", "resize-observer-polyfill": "^1.5.1", "screenfull": "^5.0.0", "tslib": "^2.4.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Y+MLoJpBXVdjsnnBjE5rOSPkQ4DK+8i5aPDzLJdIOsCpo/fiAeXcBY1Y7oWgtOK0TpOz0gFa/XcyO1UGdoqLcw=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "antd": ["antd@5.25.2", "", { "dependencies": { "@ant-design/colors": "^7.2.0", "@ant-design/cssinjs": "^1.23.0", "@ant-design/cssinjs-utils": "^1.1.3", "@ant-design/fast-color": "^2.0.6", "@ant-design/icons": "^5.6.1", "@ant-design/react-slick": "~1.1.2", "@babel/runtime": "^7.26.0", "@rc-component/color-picker": "~2.0.1", "@rc-component/mutate-observer": "^1.1.0", "@rc-component/qrcode": "~1.0.0", "@rc-component/tour": "~1.15.1", "@rc-component/trigger": "^2.2.6", "classnames": "^2.5.1", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.11", "rc-cascader": "~3.34.0", "rc-checkbox": "~3.5.0", "rc-collapse": "~3.9.0", "rc-dialog": "~9.6.0", "rc-drawer": "~7.2.0", "rc-dropdown": "~4.2.1", "rc-field-form": "~2.7.0", "rc-image": "~7.12.0", "rc-input": "~1.8.0", "rc-input-number": "~9.5.0", "rc-mentions": "~2.20.0", "rc-menu": "~9.16.1", "rc-motion": "^2.9.5", "rc-notification": "~5.6.4", "rc-pagination": "~5.1.0", "rc-picker": "~4.11.3", "rc-progress": "~4.0.0", "rc-rate": "~2.13.1", "rc-resize-observer": "^1.4.3", "rc-segmented": "~2.7.0", "rc-select": "~14.16.8", "rc-slider": "~11.1.8", "rc-steps": "~6.0.1", "rc-switch": "~4.1.0", "rc-table": "~7.50.5", "rc-tabs": "~15.6.1", "rc-textarea": "~1.10.0", "rc-tooltip": "~6.4.0", "rc-tree": "~5.13.1", "rc-tree-select": "~5.27.0", "rc-upload": "~4.9.0", "rc-util": "^5.44.4", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-7R2nUvlHhey7Trx64+hCtGXOiy+DTUs1Lv5bwbV1LzEIZIhWb0at1AM6V3K108a5lyoR9n7DX3ptlLF7uYV/DQ=="], + + "antd-style": ["antd-style@3.7.1", "", { "dependencies": { "@ant-design/cssinjs": "^1.21.1", "@babel/runtime": "^7.24.1", "@emotion/cache": "^11.11.0", "@emotion/css": "^11.11.2", "@emotion/react": "^11.11.4", "@emotion/serialize": "^1.1.3", "@emotion/utils": "^1.2.1", "use-merge-value": "^1.2.0" }, "peerDependencies": { "antd": ">=5.8.1", "react": ">=18" } }, "sha512-CQOfddVp4aOvBfCepa+Kj2e7ap+2XBINg1Kn2osdE3oQvrD7KJu/K0sfnLcFLkgCJygbxmuazYdWLKb+drPDYA=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "array-source": ["array-source@0.0.4", "", {}, "sha512-frNdc+zBn80vipY+GdcJkLEbMWj3xmzArYApmUGxoiV8uAu/ygcs9icPdsGdA26h0MkHUMW6EN2piIvVx+M5Mw=="], + + "assign-symbols": ["assign-symbols@1.0.0", "", {}, "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw=="], + + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + + "async-validator": ["async-validator@3.5.2", "", {}, "sha512-8eLCg00W9pIRZSB781UUX/H6Oskmm8xloZfr09lz5bikRpBVDlJ3hRVuxxP1SxcwsEYfJ4IU8Q19Y8/893r3rQ=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="], + + "author-regex": ["author-regex@1.0.0", "", {}, "sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g=="], + + "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="], + + "axios": ["axios@1.12.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg=="], + + "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], + + "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.13", "", { "dependencies": { "@babel/compat-data": "^7.22.6", "@babel/helper-define-polyfill-provider": "^0.6.4", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g=="], + + "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.11.1", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.3", "core-js-compat": "^3.40.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ=="], + + "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.4", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.4" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "bezier-easing": ["bezier-easing@2.1.0", "", {}, "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.24.5", "", { "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001718", "", {}, "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "chardet": ["chardet@2.1.0", "", {}, "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA=="], + + "chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="], + + "chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "chroma-js": ["chroma-js@3.1.2", "", {}, "sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-spinners": ["cli-spinners@3.3.0", "", {}, "sha512-/+40ljC3ONVnYIttjMWrlL51nItDAbBrq2upN8BPyvGU/2n5Oxw3tbNwORCaNuNqLJnxGqOfjUuhsv7l5Q4IsQ=="], + + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "colord": ["colord@2.9.3", "", {}, "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "commander": ["commander@14.0.1", "", {}, "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A=="], + + "compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], + + "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "copy-text-to-clipboard": ["copy-text-to-clipboard@2.2.0", "", {}, "sha512-WRvoIdnTs1rgPMkgA2pUOa/M4Enh2uzCwdKsOMYNAJiz/4ZvEJgmbF4OmninPmlFdAWisfeh0tH+Cpf7ni3RqQ=="], + + "copy-to-clipboard": ["copy-to-clipboard@3.3.3", "", { "dependencies": { "toggle-selection": "^1.0.6" } }, "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA=="], + + "core-js-compat": ["core-js-compat@3.42.0", "", { "dependencies": { "browserslist": "^4.24.4" } }, "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], + + "cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], + + "country-flag-icons": ["country-flag-icons@1.5.19", "", {}, "sha512-D/ZkRyj+ywJC6b2IrAN3/tpbReMUqmuRLlcKFoY/o0+EPQN9Ev/e8tV+D3+9scvu/tarxwLErNwS73C3yzxs/g=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "cytoscape": ["cytoscape@3.32.0", "", {}, "sha512-5JHBC9n75kz5851jeklCPmZWcg3hUe6sjqJvyk3+hVqFaKcHwHgxsjeN1yLmggoUc6STbtm9/NQyabQehfjvWQ=="], + + "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], + + "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], + + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-dsv": ["d3-dsv@2.0.0", "", { "dependencies": { "commander": "2", "iconv-lite": "0.4", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json", "csv2tsv": "bin/dsv2dsv", "dsv2dsv": "bin/dsv2dsv", "dsv2json": "bin/dsv2json", "json2csv": "bin/json2dsv", "json2dsv": "bin/json2dsv", "json2tsv": "bin/json2dsv", "tsv2csv": "bin/dsv2dsv", "tsv2json": "bin/dsv2json" } }, "sha512-E+Pn8UJYx9mViuIUkoc93gJGGYut6mSDKy2+XaPwccwkRGlR+LO97L2VCCRjQivTwLHkSnAJG7yo00BWY6QM+w=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-geo": ["d3-geo@1.12.1", "", { "dependencies": { "d3-array": "1" } }, "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg=="], + + "d3-hexbin": ["d3-hexbin@0.2.2", "", {}, "sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre-d3-es": ["dagre-d3-es@7.0.11", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw=="], + + "date-fns": ["date-fns@2.30.0", "", { "dependencies": { "@babel/runtime": "^7.21.0" } }, "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw=="], + + "date-fns-tz": ["date-fns-tz@1.3.8", "", { "peerDependencies": { "date-fns": ">=2.0.0" } }, "sha512-qwNXUFtMHTTU6CFSFjoJ80W8Fzzp24LntbjFFBgL/faqds4e5mo9mftoRLgr3Vi1trISsg4awSpYVsOQCRnapQ=="], + + "dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="], + + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.0.2", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg=="], + + "decode-uri-component": ["decode-uri-component@0.4.1", "", {}, "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], + + "dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.157", "", {}, "sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w=="], + + "emoji-mart": ["emoji-mart@5.6.0", "", {}, "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow=="], + + "emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], + + "entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="], + + "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], + + "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.0", "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ=="], + + "eslint-plugin-header": ["eslint-plugin-header@3.1.1", "", { "peerDependencies": { "eslint": ">=7.7.0" } }, "sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + + "eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], + + "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "estree-util-scope": ["estree-util-scope@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0" } }, "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ=="], + + "estree-util-to-js": ["estree-util-to-js@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", "source-map": "^0.7.0" } }, "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg=="], + + "estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "execa": ["execa@9.6.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw=="], + + "exsolve": ["exsolve@1.0.5", "", {}, "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + + "fast-copy": ["fast-copy@3.0.2", "", {}, "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + + "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], + + "file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="], + + "file-source": ["file-source@0.6.1", "", { "dependencies": { "stream-source": "0.3" } }, "sha512-1R1KneL7eTXmXfKxC10V/9NeGOdbsAXJ+lQ//fvvcHUgtaZcZDWNJNblxAoVOyV1cj45pOtUrR3vZTBwqcW8XA=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "filter-obj": ["filter-obj@5.1.0", "", {}, "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng=="], + + "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], + + "for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], + + "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], + + "framer-motion": ["framer-motion@12.12.2", "", { "dependencies": { "motion-dom": "^12.12.1", "motion-utils": "^12.12.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-qCszZCiGWkilL40E3VuhIJJC/CS3SIBl2IHyGK8FU30nOUhTmhBNWPrNFyozAWH/bXxwzi19vJHIGVdALF0LCg=="], + + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "geobuf": ["geobuf@3.0.2", "", { "dependencies": { "concat-stream": "^2.0.0", "pbf": "^3.2.1", "shapefile": "~0.6.6" }, "bin": { "geobuf2json": "bin/geobuf2json", "json2geobuf": "bin/json2geobuf", "shp2geobuf": "bin/shp2geobuf" } }, "sha512-ASgKwEAQQRnyNFHNvpd5uAwstbVYmiTW0Caw3fBb509tNTqXyAAPMyFs5NNihsLZhLxU1j/kjFhkhLWA9djuVg=="], + + "geojson-dissolve": ["geojson-dissolve@3.1.0", "", { "dependencies": { "@turf/meta": "^3.7.5", "geojson-flatten": "^0.2.1", "geojson-linestring-dissolve": "0.0.1", "topojson-client": "^3.0.0", "topojson-server": "^3.0.0" } }, "sha512-JXHfn+A3tU392HA703gJbjmuHaQOAE/C1KzbELCczFRFux+GdY6zt1nKb1VMBHp4LWeE7gUY2ql+g06vJqhiwQ=="], + + "geojson-flatten": ["geojson-flatten@0.2.4", "", { "dependencies": { "get-stdin": "^6.0.0", "minimist": "1.2.0" }, "bin": { "geojson-flatten": "./geojson-flatten" } }, "sha512-LiX6Jmot8adiIdZ/fthbcKKPOfWjTQchX/ggHnwMZ2e4b0I243N1ANUos0LvnzepTEsj0+D4fIJ5bKhBrWnAHA=="], + + "geojson-linestring-dissolve": ["geojson-linestring-dissolve@0.0.1", "", {}, "sha512-Y8I2/Ea28R/Xeki7msBcpMvJL2TaPfaPKP8xqueJfQ9/jEhps+iOJxOR2XCBGgVb12Z6XnDb1CMbaPfLepsLaw=="], + + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-stdin": ["get-stdin@6.0.0", "", {}, "sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g=="], + + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + + "get-value": ["get-value@2.0.6", "", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="], + + "giscus": ["giscus@1.6.0", "", { "dependencies": { "lit": "^3.2.1" } }, "sha512-Zrsi8r4t1LVW950keaWcsURuZUQwUaMKjvJgTCY125vkW6OiEBkatE7ScJDbpqKHdZwb///7FVC21SE3iFK3PQ=="], + + "glob": ["glob@11.0.3", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.0.3", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], + + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + + "hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + + "hast-util-to-estree": ["hast-util-to-estree@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "style-to-object": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-IWtwwmPskfSmma9RpzCappDUitC8t5jhAynHhc1m2+5trOgsrp7txscUSavc5Ic8PATyAjfrCK1wgtxh2cICVQ=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.2", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "style-to-object": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg=="], + + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw=="], + + "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + + "history": ["history@5.3.0", "", { "dependencies": { "@babel/runtime": "^7.7.6" } }, "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ=="], + + "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], + + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + + "i18next": ["i18next@23.16.8", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg=="], + + "i18next-browser-languagedetector": ["i18next-browser-languagedetector@7.2.2", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-6b7r75uIJDWCcCflmbof+sJ94k9UQO4X0YR62oUfqGI/GjCLVzlCwu8TFdRZIqVLzWbzNcmkmhfqKEr4TLz4HQ=="], + + "i18next-cli": ["i18next-cli@1.15.0", "", { "dependencies": { "@swc/core": "1.13.19", "chalk": "5.6.2", "chokidar": "4.0.3", "commander": "14.0.1", "execa": "9.6.0", "glob": "11.0.3", "i18next-resources-for-ts": "1.7.4", "inquirer": "12.9.6", "jiti": "2.6.1", "jsonc-parser": "3.3.1", "minimatch": "10.0.3", "ora": "9.0.0", "swc-walk": "1.0.0" }, "bin": { "i18next-cli": "dist/esm/cli.js" } }, "sha512-XgIvoSTInr0uvf8lun5t9ZDT3wa6aj/annRIYBF0WcpERowLKrRQNBLDeDNkvpddInhNlRtUOhP24F0oOLZY+Q=="], + + "i18next-resources-for-ts": ["i18next-resources-for-ts@1.7.4", "", { "dependencies": { "@babel/runtime": "^7.27.0", "yaml": "^2.7.1" }, "bin": { "i18next-resources-for-ts": "bin/i18next-resources-for-ts.js" } }, "sha512-3NpN2zasOWYR5zWA4JIdFhxrHxRJV8HEsbR7/GHSnotfjArjZzKvOzQnLFZ911QFmmcwq80saw8rccpHH+MYVQ=="], + + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "immer": ["immer@10.1.1", "", {}, "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw=="], + + "immutable": ["immutable@5.1.2", "", {}, "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ=="], + + "import-fresh": ["import-fresh@3.3.0", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], + + "inquirer": ["inquirer@12.9.6", "", { "dependencies": { "@inquirer/ansi": "^1.0.0", "@inquirer/core": "^10.2.2", "@inquirer/prompts": "^7.8.6", "@inquirer/type": "^3.0.8", "mute-stream": "^2.0.0", "run-async": "^4.0.5", "rxjs": "^7.8.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-603xXOgyfxhuis4nfnWaZrMaotNT0Km9XwwBNWUKbIDqeCY89jGr2F9YPEMiNhU6XjIP4VoWISMBFfcc5NgrTw=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "intersection-observer": ["intersection-observer@0.12.2", "", {}, "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-extendable": ["is-extendable@1.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4" } }, "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], + + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "isarray": ["isarray@0.0.1", "", {}, "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="], + + "jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json2mq": ["json2mq@0.2.0", "", { "dependencies": { "string-convert": "^0.2.0" } }, "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="], + + "katex": ["katex@0.16.22", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + + "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], + + "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], + + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + + "leva": ["leva@0.10.0", "", { "dependencies": { "@radix-ui/react-portal": "1.0.2", "@radix-ui/react-tooltip": "1.0.5", "@stitches/react": "^1.2.8", "@use-gesture/react": "^10.2.5", "colord": "^2.9.2", "dequal": "^2.0.2", "merge-value": "^1.0.0", "react-colorful": "^5.5.1", "react-dropzone": "^12.0.0", "v8n": "^1.3.3", "zustand": "^3.6.9" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-RiNJWmeqQdKIeHuVXgshmxIHu144a2AMYtLxKf8Nm1j93pisDPexuQDHKNdQlbo37wdyDQibLjY9JKGIiD7gaw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "lit": ["lit@3.3.0", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw=="], + + "lit-element": ["lit-element@4.2.0", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.2.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-MGrXJVAI5x+Bfth/pU9Kst1iWID6GHDLEzFEnyULB/sFiRLgkd8NPK/PeeXxktA3T6EIIaq8U3KcbTU5XFcP2Q=="], + + "lit-html": ["lit-html@3.3.0", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-RHoswrFAxY2d8Cf2mm4OZ1DgzCoBKUKSPvA1fhtSELxUERq2aQQ2h05pO9j81gS1o7RIRJ+CePLogfyahwmynw=="], + + "local-pkg": ["local-pkg@1.1.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.0.1", "quansync": "^0.2.8" } }, "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lottie-web": ["lottie-web@5.12.2", "", {}, "sha512-uvhvYPC8kGPjXT3MyKMrL3JitEAmDMp30lVkuq/590Mw9ok6pWcFCwXJveo0t5uqYw1UREQHofD+jVpdjBv8wg=="], + + "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@0.511.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w=="], + + "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="], + + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.1.3", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-newline-to-break": ["mdast-util-newline-to-break@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-find-and-replace": "^3.0.0" } }, "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="], + + "merge-value": ["merge-value@1.0.0", "", { "dependencies": { "get-value": "^2.0.6", "is-extendable": "^1.0.0", "mixin-deep": "^1.2.0", "set-value": "^2.0.0" } }, "sha512-fJMmvat4NeKz63Uv9iHWcPDjCWcCkoiRoajRTEO8hlhUC6rwaHg0QCF9hBOTjZmm4JuglPckPSTtcuJL5kp0TQ=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "mermaid": ["mermaid@11.6.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.0.4", "@iconify/utils": "^2.1.33", "@mermaid-js/parser": "^0.4.0", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.11", "dayjs": "^1.11.13", "dompurify": "^3.2.4", "katex": "^0.16.9", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^15.0.7", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg=="], + + "micromark": ["micromark@4.0.1", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-eBPdkcoCNvYcxQOAKAlceo5SNdzZWfF+FcSupREAzdAh9rRmE239CEQAiTwIgblwnoM8zzj35sZ5ZwvSEOF6Kw=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.2", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ub2ncQv+fwD70/l4ou27b4YzfNaCJOvyX4HxXU15m7mpYY+rjuWzsLIPZHJL253Z643RpbcP1oeIJlQ/SKW67g=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="], + + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-sI0nwhUDz97xyzqJAbHQhp5TfaxEvZZZ2JDqUo+7NvyIYG6BZ5CPPqj2ogUoPJlmXHBnyZUzISg9+oUmU6tUjQ=="], + + "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.1", "", { "dependencies": { "@types/acorn": "^4.0.0", "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-vNuFb9czP8QCtAQcEJn0UJQJZA8Dk6DXKBqx+bg/w0WGuSxDxNr7hErW89tHUY31dUW4NqEOWwmEUNhjTFmHkg=="], + + "micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="], + + "micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="], + + "micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-5E5I2pFzJyg2CtemqAbcyCktpHXuJbABnsb32wX2U8IQKhhVFBqkcZR5LRm1WVoFqa4kTueZK4abep7wdo9nrw=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.2", "", { "dependencies": { "@types/acorn": "^4.0.0", "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-Fk+xmBrOv9QZnEDguL9OI9/NQQp6Hz4FuQ4YmCb/5V7+9eAh1s6AYSvL20kHkD67YIg7EpE54TiSlcsf3vyZgA=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.0.3", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VXJJuNxYWSoYL6AJ6OQECCFGhIU2GGHMw8tahogePBrjkG8aCCas3ibkp7RnVOSTClg2is05/R7maAhF1XyQMg=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.1", "", {}, "sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "mixin-deep": ["mixin-deep@1.3.2", "", { "dependencies": { "for-in": "^1.0.2", "is-extendable": "^1.0.1" } }, "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA=="], + + "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], + + "motion-dom": ["motion-dom@12.12.1", "", { "dependencies": { "motion-utils": "^12.12.1" } }, "sha512-GXq/uUbZBEiFFE+K1Z/sxdPdadMdfJ/jmBALDfIuHGi0NmtealLOfH9FqT+6aNPgVx8ilq0DtYmyQlo6Uj9LKQ=="], + + "motion-utils": ["motion-utils@12.12.1", "", {}, "sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], + + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + + "numeral": ["numeral@2.0.6", "", {}, "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "on-change": ["on-change@4.0.2", "", {}, "sha512-cMtCyuJmTx/bg2HCpHo3ZLeF7FZnBOapLqZHr2AlLeJ5Ul0Zu2mUJJz051Fdwu/Et2YW04ZD+TtU+gVy0ACNCA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "ora": ["ora@9.0.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.2.2", "string-width": "^8.1.0", "strip-ansi": "^7.1.2" } }, "sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "package-manager-detector": ["package-manager-detector@1.3.0", "", {}, "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-author": ["parse-author@2.0.0", "", { "dependencies": { "author-regex": "^1.0.0" } }, "sha512-yx5DfvkN8JsHL2xk2Os9oTia467qnvRgey4ahSm2X8epehBLx/gWLcy5KI+Y36ful5DzGbCS6RazqZGgy1gHNw=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + + "parse-svg-path": ["parse-svg-path@0.1.2", "", {}, "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-scurry": ["path-scurry@2.0.0", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg=="], + + "path-source": ["path-source@0.1.3", "", { "dependencies": { "array-source": "0.0", "file-source": "0.6" } }, "sha512-dWRHm5mIw5kw0cs3QZLNmpUWty48f5+5v9nWD2dw3Y0Hf+s01Ag8iJEWV0Sm0kocE8kK27DrIowha03e1YR+Qw=="], + + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pbf": ["pbf@3.3.0", "", { "dependencies": { "ieee754": "^1.1.12", "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "pkg-types": ["pkg-types@2.1.0", "", { "dependencies": { "confbox": "^0.2.1", "exsolve": "^1.0.1", "pathe": "^2.0.3" } }, "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A=="], + + "point-at-length": ["point-at-length@1.1.0", "", { "dependencies": { "abs-svg-path": "~0.1.1", "isarray": "~0.0.1", "parse-svg-path": "~0.1.1" } }, "sha512-nNHDk9rNEh/91o2Y8kHLzBLNpLf80RYd2gCun9ss+V0ytRSf6XhryBTx071fesktjbachRmGuUbId+JQmzhRXw=="], + + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], + + "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + + "polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="], + + "postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], + + "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], + + "postcss-js": ["postcss-js@4.0.1", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw=="], + + "postcss-load-config": ["postcss-load-config@4.0.2", "", { "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.4.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ=="], + + "prettier-package-json": ["prettier-package-json@2.8.0", "", { "dependencies": { "@types/parse-author": "^2.0.0", "commander": "^4.0.1", "cosmiconfig": "^7.0.0", "fs-extra": "^10.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4", "parse-author": "^2.0.0", "sort-object-keys": "^1.1.3", "sort-order": "^1.0.1" }, "bin": { "prettier-package-json": "bin/prettier-package-json" } }, "sha512-WxtodH/wWavfw3MR7yK/GrS4pASEQ+iSTkdtSxPJWvqzG55ir5nvbLt9rw5AOiEcqqPCRM92WCtR1rk3TG3JSQ=="], + + "prettier-plugin-astro": ["prettier-plugin-astro@0.14.1", "", { "dependencies": { "@astrojs/compiler": "^2.9.1", "prettier": "^3.0.0", "sass-formatter": "^0.7.6" } }, "sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw=="], + + "prettier-plugin-curly-and-jsdoc": ["prettier-plugin-curly-and-jsdoc@3.1.0", "", { "peerDependencies": { "prettier": "^3.0.0" } }, "sha512-4QMOHnLlkP2jTRWS0MFH6j+cuOiXLvXOqCLKbtwwVd8PPyq8NenW5AAwfwqiTNHBQG/DmzViPphRrwgN0XkUVQ=="], + + "prettier-plugin-pkgsort": ["prettier-plugin-pkgsort@0.2.1", "", { "dependencies": { "prettier-package-json": "^2.8.0" }, "peerDependencies": { "prettier": "^3.0.0" } }, "sha512-/k5MIw84EhgoH7dmq4+6ozHjJ0VYbxbw17g4C+WPGHODkLivGwJoA6U1YPR/KObyRDMQJHXAfXKu++9smg7Jyw=="], + + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "prismjs": ["prismjs@1.29.0", "", {}, "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], + + "protocol-buffers-schema": ["protocol-buffers-schema@3.6.0", "", {}, "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], + + "quansync": ["quansync@0.2.10", "", {}, "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A=="], + + "query-string": ["query-string@9.2.0", "", { "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", "split-on-first": "^3.0.0" } }, "sha512-YIRhrHujoQxhexwRLxfy3VSjOXmvZRd2nyw1PwL1UUqZ/ys1dEZd1+NSgXkne2l/4X/7OXkigEAuhTX0g/ivJQ=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "rc-cascader": ["rc-cascader@3.34.0", "", { "dependencies": { "@babel/runtime": "^7.25.7", "classnames": "^2.3.1", "rc-select": "~14.16.2", "rc-tree": "~5.13.0", "rc-util": "^5.43.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag=="], + + "rc-checkbox": ["rc-checkbox@3.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.25.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg=="], + + "rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="], + + "rc-dialog": ["rc-dialog@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", "classnames": "^2.2.6", "rc-motion": "^2.3.0", "rc-util": "^5.21.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg=="], + + "rc-drawer": ["rc-drawer@7.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.9", "@rc-component/portal": "^1.1.1", "classnames": "^2.2.6", "rc-motion": "^2.6.1", "rc-util": "^5.38.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-9lOQ7kBekEJRdEpScHvtmEtXnAsy+NGDXiRWc2ZVC7QXAazNVbeT4EraQKYwCME8BJLa8Bxqxvs5swwyOepRwg=="], + + "rc-dropdown": ["rc-dropdown@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.6", "rc-util": "^5.44.1" }, "peerDependencies": { "react": ">=16.11.0", "react-dom": ">=16.11.0" } }, "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA=="], + + "rc-field-form": ["rc-field-form@2.7.0", "", { "dependencies": { "@babel/runtime": "^7.18.0", "@rc-component/async-validator": "^5.0.3", "rc-util": "^5.32.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-hgKsCay2taxzVnBPZl+1n4ZondsV78G++XVsMIJCAoioMjlMQR9YwAp7JZDIECzIu2Z66R+f4SFIRrO2DjDNAA=="], + + "rc-footer": ["rc-footer@0.6.8", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-JBZ+xcb6kkex8XnBd4VHw1ZxjV6kmcwUumSHaIFdka2qzMCo7Klcy4sI6G0XtUpG/vtpislQCc+S9Bc+NLHYMg=="], + + "rc-image": ["rc-image@7.12.0", "", { "dependencies": { "@babel/runtime": "^7.11.2", "@rc-component/portal": "^1.0.2", "classnames": "^2.2.6", "rc-dialog": "~9.6.0", "rc-motion": "^2.6.2", "rc-util": "^5.34.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q=="], + + "rc-input": ["rc-input@1.8.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-util": "^5.18.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA=="], + + "rc-input-number": ["rc-input-number@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/mini-decimal": "^1.0.1", "classnames": "^2.2.5", "rc-input": "~1.8.0", "rc-util": "^5.40.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag=="], + + "rc-mentions": ["rc-mentions@2.20.0", "", { "dependencies": { "@babel/runtime": "^7.22.5", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.6", "rc-input": "~1.8.0", "rc-menu": "~9.16.0", "rc-textarea": "~1.10.0", "rc-util": "^5.34.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ=="], + + "rc-menu": ["rc-menu@9.16.1", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/trigger": "^2.0.0", "classnames": "2.x", "rc-motion": "^2.4.3", "rc-overflow": "^1.3.1", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg=="], + + "rc-motion": ["rc-motion@2.9.5", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA=="], + + "rc-notification": ["rc-notification@5.6.4", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.9.0", "rc-util": "^5.20.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw=="], + + "rc-overflow": ["rc-overflow@1.4.1", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-resize-observer": "^1.0.0", "rc-util": "^5.37.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3MoPQQPV1uKyOMVNd6SZfONi+f3st0r8PksexIdBTeIYbMX0Jr+k7pHEDvsXtR4BpCv90/Pv2MovVNhktKrwvw=="], + + "rc-pagination": ["rc-pagination@5.1.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.38.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ=="], + + "rc-picker": ["rc-picker@4.11.3", "", { "dependencies": { "@babel/runtime": "^7.24.7", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.1", "rc-overflow": "^1.3.2", "rc-resize-observer": "^1.4.0", "rc-util": "^5.43.0" }, "peerDependencies": { "date-fns": ">= 2.x", "dayjs": ">= 1.x", "luxon": ">= 3.x", "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" }, "optionalPeers": ["date-fns", "dayjs", "luxon", "moment"] }, "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg=="], + + "rc-progress": ["rc-progress@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.6", "rc-util": "^5.16.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw=="], + + "rc-rate": ["rc-rate@2.13.1", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.5", "rc-util": "^5.0.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q=="], + + "rc-resize-observer": ["rc-resize-observer@1.4.3", "", { "dependencies": { "@babel/runtime": "^7.20.7", "classnames": "^2.2.1", "rc-util": "^5.44.1", "resize-observer-polyfill": "^1.5.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ=="], + + "rc-segmented": ["rc-segmented@2.7.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-motion": "^2.4.4", "rc-util": "^5.17.0" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-liijAjXz+KnTRVnxxXG2sYDGd6iLL7VpGGdR8gwoxAXy2KglviKCxLWZdjKYJzYzGSUwKDSTdYk8brj54Bn5BA=="], + + "rc-select": ["rc-select@14.16.8", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/trigger": "^2.1.1", "classnames": "2.x", "rc-motion": "^2.0.1", "rc-overflow": "^1.3.1", "rc-util": "^5.16.1", "rc-virtual-list": "^3.5.2" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg=="], + + "rc-slider": ["rc-slider@11.1.8", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.5", "rc-util": "^5.36.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-2gg/72YFSpKP+Ja5AjC5DPL1YnV8DEITDQrcc1eASrUYjl0esptaBVJBh5nLTXCCp15eD8EuGjwezVGSHhs9tQ=="], + + "rc-steps": ["rc-steps@6.0.1", "", { "dependencies": { "@babel/runtime": "^7.16.7", "classnames": "^2.2.3", "rc-util": "^5.16.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g=="], + + "rc-switch": ["rc-switch@4.1.0", "", { "dependencies": { "@babel/runtime": "^7.21.0", "classnames": "^2.2.1", "rc-util": "^5.30.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg=="], + + "rc-table": ["rc-table@7.50.5", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/context": "^1.4.0", "classnames": "^2.2.5", "rc-resize-observer": "^1.1.0", "rc-util": "^5.44.3", "rc-virtual-list": "^3.14.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-FDZu8aolhSYd3v9KOc3lZOVAU77wmRRu44R0Wfb8Oj1dXRUsloFaXMSl6f7yuWZUxArJTli7k8TEOX2mvhDl4A=="], + + "rc-tabs": ["rc-tabs@15.6.1", "", { "dependencies": { "@babel/runtime": "^7.11.2", "classnames": "2.x", "rc-dropdown": "~4.2.0", "rc-menu": "~9.16.0", "rc-motion": "^2.6.2", "rc-resize-observer": "^1.0.0", "rc-util": "^5.34.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-/HzDV1VqOsUWyuC0c6AkxVYFjvx9+rFPKZ32ejxX0Uc7QCzcEjTA9/xMgv4HemPKwzBNX8KhGVbbumDjnj92aA=="], + + "rc-textarea": ["rc-textarea@1.10.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.1", "rc-input": "~1.8.0", "rc-resize-observer": "^1.0.0", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ai9IkanNuyBS4x6sOL8qu/Ld40e6cEs6pgk93R+XLYg0mDSjNBGey6/ZpDs5+gNLD7urQ14po3V6Ck2dJLt9SA=="], + + "rc-tooltip": ["rc-tooltip@6.4.0", "", { "dependencies": { "@babel/runtime": "^7.11.2", "@rc-component/trigger": "^2.0.0", "classnames": "^2.3.1", "rc-util": "^5.44.3" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g=="], + + "rc-tree": ["rc-tree@5.13.1", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.0.1", "rc-util": "^5.16.1", "rc-virtual-list": "^3.5.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A=="], + + "rc-tree-select": ["rc-tree-select@5.27.0", "", { "dependencies": { "@babel/runtime": "^7.25.7", "classnames": "2.x", "rc-select": "~14.16.2", "rc-tree": "~5.13.0", "rc-util": "^5.43.0" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww=="], + + "rc-upload": ["rc-upload@4.9.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "classnames": "^2.2.5", "rc-util": "^5.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-pAzlPnyiFn1GCtEybEG2m9nXNzQyWXqWV2xFYCmDxjN9HzyjS5Pz2F+pbNdYw8mMJsixLEKLG0wVy9vOGxJMJA=="], + + "rc-util": ["rc-util@5.44.4", "", { "dependencies": { "@babel/runtime": "^7.18.3", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w=="], + + "rc-virtual-list": ["rc-virtual-list@3.18.6", "", { "dependencies": { "@babel/runtime": "^7.20.0", "classnames": "^2.2.6", "rc-resize-observer": "^1.0.0", "rc-util": "^5.36.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-TQ5SsutL3McvWmmxqQtMIbfeoE3dGjJrRSfKekgby7WQMpPIFvv4ghytp5Z0s3D8Nik9i9YNOCqHBfk86AwgAA=="], + + "re-resizable": ["re-resizable@6.11.2", "", { "peerDependencies": { "react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-avatar-editor": ["react-avatar-editor@13.0.2", "", { "dependencies": { "@babel/plugin-transform-runtime": "^7.12.1", "@babel/runtime": "^7.12.5", "prop-types": "^15.7.2" }, "peerDependencies": { "react": "^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react-dom": "^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" } }, "sha512-a4ajbi7lwDh98kgEtSEeKMu0vs0CHTczkq4Xcxr1EiwMFH1GlgHCEtwGU8q/H5W8SeLnH4KPK8LUjEEaZXklxQ=="], + + "react-colorful": ["react-colorful@5.6.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "react-draggable": ["react-draggable@4.4.6", "", { "dependencies": { "clsx": "^1.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw=="], + + "react-dropzone": ["react-dropzone@14.3.5", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ=="], + + "react-error-boundary": ["react-error-boundary@5.0.0", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "react": ">=16.13.1" } }, "sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ=="], + + "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], + + "react-fireworks": ["react-fireworks@1.0.4", "", {}, "sha512-jj1a+HTicB4pR6g2lqhVyAox0GTE0TOrZK2XaJFRYOwltgQWeYErZxnvU9+zH/blY+Hpmu9IKyb39OD3KcCMJw=="], + + "react-hotkeys-hook": ["react-hotkeys-hook@5.1.0", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-GCNGXjBzV9buOS3REoQFmSmE4WTvBhYQ0YrAeeMZI83bhXg3dRWsLHXDutcVDdEjwJqJCxk5iewWYX5LtFUd7g=="], + + "react-i18next": ["react-i18next@13.5.0", "", { "dependencies": { "@babel/runtime": "^7.22.5", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA=="], + + "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-layout-kit": ["react-layout-kit@1.9.1", "", { "dependencies": { "@babel/runtime": "^7", "@emotion/css": "^11" }, "peerDependencies": { "react": ">=18" } }, "sha512-tQO5J+Ajppu2JCdhgFaFbWCg01WJXXaQ5vg8cxzsv8vVeogJKGFgoJm9OI2saDFchfKP3RABd+aRY5vB++poqw=="], + + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + + "react-merge-refs": ["react-merge-refs@3.0.2", "", { "peerDependencies": { "react": ">=16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["react"] }, "sha512-MSZAfwFfdbEvwkKWP5EI5chuLYnNUxNS7vyS0i1Jp+wtd8J4Ga2ddzhaE68aMol2Z4vCnRM/oGOo1a3V75UPlw=="], + + "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], + + "react-resizable": ["react-resizable@3.0.5", "", { "dependencies": { "prop-types": "15.x", "react-draggable": "^4.0.3" }, "peerDependencies": { "react": ">= 16.3" } }, "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w=="], + + "react-rnd": ["react-rnd@10.5.2", "", { "dependencies": { "re-resizable": "6.11.2", "react-draggable": "4.4.6", "tslib": "2.6.2" }, "peerDependencies": { "react": ">=16.3.0", "react-dom": ">=16.3.0" } }, "sha512-0Tm4x7k7pfHf2snewJA8x7Nwgt3LV+58MVEWOVsFjk51eYruFEa6Wy7BNdxt4/lH0wIRsu7Gm3KjSXY2w7YaNw=="], + + "react-router": ["react-router@6.28.1", "", { "dependencies": { "@remix-run/router": "1.21.0" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-2omQTA3rkMljmrvvo6WtewGdVh45SpL9hGiCI9uUrwGGfNFDIvGK4gYJsKlJoNVi6AQZcopSCballL+QGOm7fA=="], + + "react-router-dom": ["react-router-dom@6.28.1", "", { "dependencies": { "@remix-run/router": "1.21.0", "react-router": "6.28.1" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-YraE27C/RdjcZwl5UCqF/ffXnZDxpJdk9Q6jw38SZHjXs7NNdpViq2l2c7fO7+4uWaEfcwfGCv3RSg4e1By/fQ=="], + + "react-telegram-login": ["react-telegram-login@1.1.2", "", { "dependencies": { "react": "^16.13.1" } }, "sha512-pDP+bvfaklWgnK5O6yvZnIwgky0nnYUU6Zhk0EjdMSkPsLQoOzZRsXIoZnbxyBXhi7346bsxMH+EwwJPTxClDw=="], + + "react-toastify": ["react-toastify@9.1.3", "", { "dependencies": { "clsx": "^1.1.1" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg=="], + + "react-turnstile": ["react-turnstile@1.1.4", "", { "peerDependencies": { "react": ">= 16.13.1", "react-dom": ">= 16.13.1" } }, "sha512-oluyRWADdsufCt5eMqacW4gfw8/csr6Tk+fmuaMx0PWMKP1SX1iCviLvD2D5w92eAzIYDHi/krUWGHhlfzxTpQ=="], + + "react-window": ["react-window@1.8.11", "", { "dependencies": { "@babel/runtime": "^7.0.0", "memoize-one": ">=3.1.1 <6" }, "peerDependencies": { "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ=="], + + "react-zoom-pan-pinch": ["react-zoom-pan-pinch@3.7.0", "", { "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA=="], + + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], + + "recma-jsx": ["recma-jsx@1.0.0", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" } }, "sha512-5vwkv65qWwYxg+Atz95acp8DMu1JDSqdGkA2Of1j6rCreyFUE/gp15fC8MnGEuG1W68UKjM6x6+YTWIh7hZM/Q=="], + + "recma-parse": ["recma-parse@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "esast-util-from-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ=="], + + "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], + + "regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="], + + "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="], + + "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], + + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + + "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], + + "remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="], + + "remark-mdx": ["remark-mdx@3.1.0", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Ngl/H3YXyBV9RcRNdlYsZujAmhsxwzxpDzpDEhFBVAGthS4GDgnctpDjgFl/ULx5UEDzqtW1cyBSNKqYYrqLBA=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="], + + "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="], + + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + + "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], + + "rollup": ["rollup@4.30.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.30.0", "@rollup/rollup-android-arm64": "4.30.0", "@rollup/rollup-darwin-arm64": "4.30.0", "@rollup/rollup-darwin-x64": "4.30.0", "@rollup/rollup-freebsd-arm64": "4.30.0", "@rollup/rollup-freebsd-x64": "4.30.0", "@rollup/rollup-linux-arm-gnueabihf": "4.30.0", "@rollup/rollup-linux-arm-musleabihf": "4.30.0", "@rollup/rollup-linux-arm64-gnu": "4.30.0", "@rollup/rollup-linux-arm64-musl": "4.30.0", "@rollup/rollup-linux-loongarch64-gnu": "4.30.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.30.0", "@rollup/rollup-linux-riscv64-gnu": "4.30.0", "@rollup/rollup-linux-s390x-gnu": "4.30.0", "@rollup/rollup-linux-x64-gnu": "4.30.0", "@rollup/rollup-linux-x64-musl": "4.30.0", "@rollup/rollup-win32-arm64-msvc": "4.30.0", "@rollup/rollup-win32-ia32-msvc": "4.30.0", "@rollup/rollup-win32-x64-msvc": "4.30.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-sDnr1pcjTgUT69qBksNF1N1anwfbyYG6TBQ22b03bII8EdiUQ7J0TlozVaTMjT/eEJAO49e1ndV7t+UZfL1+vA=="], + + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + + "run-async": ["run-async@4.0.6", "", {}, "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + + "s.color": ["s.color@0.0.15", "", {}, "sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sass": ["sass@1.89.1", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-eMLLkl+qz7tx/0cJ9wI+w09GQ2zodTkcE/aVfywwdlRcI3EO19xGnbmJwg/JMIm+5MxVJ6outddLZ4Von4E++Q=="], + + "sass-formatter": ["sass-formatter@0.7.9", "", { "dependencies": { "suf-log": "^2.5.3" } }, "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "screenfull": ["screenfull@5.2.0", "", {}, "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="], + + "scroll-into-view-if-needed": ["scroll-into-view-if-needed@2.2.31", "", { "dependencies": { "compute-scroll-into-view": "^1.0.20" } }, "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + + "set-value": ["set-value@2.0.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-extendable": "^0.1.1", "is-plain-object": "^2.0.3", "split-string": "^3.0.1" } }, "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw=="], + + "shapefile": ["shapefile@0.6.6", "", { "dependencies": { "array-source": "0.0", "commander": "2", "path-source": "0.1", "slice-source": "0.4", "stream-source": "0.3", "text-encoding": "^0.6.4" }, "bin": { "dbf2json": "bin/dbf2json", "shp2json": "bin/shp2json" } }, "sha512-rLGSWeK2ufzCVx05wYd+xrWnOOdSV7xNUW5/XFgx3Bc02hBkpMlrd2F1dDII7/jhWzv0MSyBFh5uJIy9hLdfuw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "simple-statistics": ["simple-statistics@7.8.7", "", {}, "sha512-ed5FwTNYvkMTfbCai1U+r3symP+lIPKWCqKdudpN4NFNMn9RtDlFtSyAQhCp4oPH0YBjWu/qnW+5q5ZkPB3uHQ=="], + + "simplify-geojson": ["simplify-geojson@1.0.5", "", { "dependencies": { "concat-stream": "~1.4.1", "minimist": "1.2.6", "simplify-geometry": "0.0.2" }, "bin": { "simplify-geojson": "cli.js" } }, "sha512-02l1W4UipP5ivNVq6kX15mAzCRIV1oI3tz0FUEyOsNiv1ltuFDjbNhO+nbv/xhbDEtKqWLYuzpWhUsJrjR/ypA=="], + + "simplify-geometry": ["simplify-geometry@0.0.2", "", {}, "sha512-ZEyrplkqgCqDlL7V8GbbYgTLlcnNF+MWWUdy8s8ZeJru50bnI71rDew/I+HG36QS2mPOYAq1ZjwNXxHJ8XOVBw=="], + + "slice-source": ["slice-source@0.4.1", "", {}, "sha512-YiuPbxpCj4hD9Qs06hGAz/OZhQ0eDuALN0lRWJez0eD/RevzKqGdUx1IOMUnXgpr+sXZLq3g8ERwbAH0bCb8vg=="], + + "sort-object-keys": ["sort-object-keys@1.1.3", "", {}, "sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg=="], + + "sort-order": ["sort-order@1.1.2", "", {}, "sha512-Q8tOrwB1TSv9fNUXym9st3TZJODtmcOIi2JWCkVNQPrRg17KPwlpwweTEb7pMwUIFMTAgx2/JsQQXEPFzYQj3A=="], + + "source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "split-on-first": ["split-on-first@3.0.0", "", {}, "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA=="], + + "split-string": ["split-string@3.1.0", "", { "dependencies": { "extend-shallow": "^3.0.0" } }, "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw=="], + + "sse.js": ["sse.js@2.6.0", "", {}, "sha512-eGEqOwiPX9Cm+KsOYkcz7HIEqWUSOFeChr0sT515hDOBLvQy5yxaLSZx9JWMhwjf75CXJq+7cgG1MKNh9GQ36w=="], + + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], + + "stream-source": ["stream-source@0.3.5", "", {}, "sha512-ZuEDP9sgjiAwUVoDModftG0JtYiLUV8K4ljYD1VyUMRWtbVf92474o4kuuul43iZ8t/hRuiDAx1dIJSvirrK/g=="], + + "string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="], + + "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "style-to-object": ["style-to-object@1.0.8", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g=="], + + "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], + + "sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="], + + "suf-log": ["suf-log@2.5.3", "", { "dependencies": { "s.color": "0.0.15" } }, "sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "swc-walk": ["swc-walk@1.0.0", "", { "dependencies": { "acorn-walk": "^8.3.4" } }, "sha512-QnEvBZ/ZRsUrXCz/Z3Kto06xUsoqUTo3doj/UvOD0RfamEgqlhpgpyCykFAwiUcuDrODShzlxuDqDPf2Wc+DvQ=="], + + "swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="], + + "tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="], + + "tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="], + + "text-encoding": ["text-encoding@0.6.4", "", {}, "sha512-hJnc6Qg3dWoOMkqP53F0dzRIgtmsAge09kxUIqGrEUS4qr5rWLckGYaQAVr+opBrIMRErGgy6f5aPnyPpyGRfg=="], + + "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="], + + "tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toggle-selection": ["toggle-selection@1.0.6", "", {}, "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ=="], + + "topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="], + + "topojson-server": ["topojson-server@3.0.1", "", { "dependencies": { "commander": "2" }, "bin": { "geo2topo": "bin/geo2topo" } }, "sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "ts-md5": ["ts-md5@1.3.1", "", {}, "sha512-DiwiXfwvcTeZ5wCE0z+2A9EseZsztaiZtGrtSaY5JOD7ekPnR/GoIVD5gXZAlK9Na9Kvpo9Waz5rW64WKAWApg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], + + "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], + + "typescript": ["typescript@4.4.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-gzP+t5W4hdy4c+68bfcv0t400HVJMMd2+H9B7gae1nQlBzCqvrXX+6GL/b3GAgyTH966pzrZ70/fRjwAtZksSQ=="], + + "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], + + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], + + "unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], + + "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="], + + "use-debounce": ["use-debounce@10.0.4", "", { "peerDependencies": { "react": "*" } }, "sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw=="], + + "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="], + + "use-merge-value": ["use-merge-value@1.2.0", "", { "peerDependencies": { "react": ">= 16.x" } }, "sha512-DXgG0kkgJN45TcyoXL49vJnn55LehnrmoHc7MbKi+QDBvr8dsesqws8UlyIWGHMR+JXgxc1nvY+jDGMlycsUcw=="], + + "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], + + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + + "v8n": ["v8n@1.5.1", "", {}, "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="], + + "vite": ["vite@5.4.11", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q=="], + + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "vscode-uri": ["vscode-uri@3.0.8", "", {}, "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw=="], + + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], + + "zustand": ["zustand@3.7.2", "", { "peerDependencies": { "react": ">=16.8" }, "optionalPeers": ["react"] }, "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@babel/helper-compilation-targets/browserslist": ["browserslist@4.24.3", "", { "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" } }, "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA=="], + + "@babel/helper-define-polyfill-provider/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + + "@babel/plugin-transform-runtime/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], + + "@douyinfe/semi-foundation/remark-gfm": ["remark-gfm@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA=="], + + "@emotion/babel-plugin/@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], + + "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + + "@emotion/babel-plugin/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + + "@emotion/babel-plugin/stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], + + "@emotion/cache/stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], + + "@emotion/serialize/@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], + + "@emotion/serialize/@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], + + "@iconify/utils/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + + "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "@lobehub/fluent-emoji/lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="], + + "@lobehub/icons/lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="], + + "@lobehub/ui/@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@lobehub/ui/lucide-react": ["lucide-react@0.484.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oZy8coK9kZzvqhSgfbGkPtTgyjpBvs3ukLgDPv14dSOZtBtboryWF5o8i3qen7QbGg7JhiJBz5mK1p8YoMZTLQ=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="], + + "@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@0.7.2", "", { "dependencies": { "@floating-ui/dom": "^0.5.3", "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg=="], + + "@radix-ui/react-popper/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="], + + "@radix-ui/react-presence/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw=="], + + "@radix-ui/react-tooltip/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="], + + "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw=="], + + "@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="], + + "antd/rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="], + + "antd/scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], + + "cosmiconfig/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], + + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + + "d3/d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3/d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-dsv/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "d3-fetch/d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3-geo/d3-array": ["d3-array@1.2.4", "", {}, "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="], + + "d3-sankey/d3-array": ["d3-array@1.2.4", "", {}, "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + + "esast-util-from-js/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + + "extend-shallow/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "geojson-dissolve/@turf/meta": ["@turf/meta@3.14.0", "", {}, "sha512-OtXqLQuR9hlQ/HkAF/OdzRea7E0eZK1ay8y8CBXkoO2R6v34CsDrWYLMSo0ZzMsaQDpKo76NPP2GGo+PyG1cSg=="], + + "geojson-flatten/minimist": ["minimist@1.2.0", "", {}, "sha512-7Wl+Jz+IGWuSdgsQEJ4JunV0si/iMhg42MnQQG6h1R6TNeVenp4U9x5CC5v/gYqz/fENLQITAWXidNtVL0NNbw=="], + + "glob/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + + "hast-util-from-parse5/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "hast-util-to-html/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "hastscript/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "i18next-cli/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "i18next-cli/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + + "i18next-resources-for-ts/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "leva/react-dropzone": ["react-dropzone@12.1.0", "", { "dependencies": { "attr-accept": "^2.2.2", "file-selector": "^0.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8" } }, "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog=="], + + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "mermaid/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + + "micromark-extension-mdxjs/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + + "mlly/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + + "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "ora/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "path-scurry/lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], + + "prettier-package-json/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "prettier-package-json/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "react-draggable/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], + + "react-rnd/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], + + "react-telegram-login/react": ["react@16.14.0", "", { "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2" } }, "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g=="], + + "react-toastify/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], + + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "set-value/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + + "shapefile/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "simplify-geojson/concat-stream": ["concat-stream@1.4.11", "", { "dependencies": { "inherits": "~2.0.1", "readable-stream": "~1.1.9", "typedarray": "~0.0.5" } }, "sha512-X3JMh8+4je3U1cQpG87+f9lXHDrqcb2MVLg9L7o8b1UZ0DzhRrUpdn65ttzu10PpJPPI3MQNkis+oha6TSA9Mw=="], + + "split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="], + + "string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "sucrase/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], + + "tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "topojson-server/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "vite/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], + + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@babel/helper-compilation-targets/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001690", "", {}, "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w=="], + + "@babel/helper-compilation-targets/browserslist/electron-to-chromium": ["electron-to-chromium@1.5.76", "", {}, "sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ=="], + + "@babel/helper-compilation-targets/browserslist/update-browserslist-db": ["update-browserslist-db@1.1.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.0" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.27.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.1", "@babel/parser": "^7.27.1", "@babel/template": "^7.27.1", "@babel/types": "^7.27.1", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="], + + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], + + "@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom": ["@floating-ui/dom@0.5.4", "", { "dependencies": { "@floating-ui/core": "^0.7.3" } }, "sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="], + + "antd/scroll-into-view-if-needed/compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], + + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + + "d3-fetch/d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-fetch/d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + + "d3/d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3/d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "leva/react-dropzone/file-selector": ["file-selector@0.5.0", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="], + + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "ora/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + + "simplify-geojson/concat-stream/readable-stream": ["readable-stream@1.1.14", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "0.0.1", "string_decoder": "~0.10.x" } }, "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ=="], + + "simplify-geojson/concat-stream/typedarray": ["typedarray@0.0.7", "", {}, "sha512-ueeb9YybpjhivjbHP2LdFDAjbS948fGEPj+ACAMs4xCMmh72OCOMQWBQKlaN4ZNQ04yfLSDLSx1tGRIoWimObQ=="], + + "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + + "sucrase/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "sucrase/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "sucrase/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.27.1", "", { "dependencies": { "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.27.2", "", { "dependencies": { "@babel/types": "^7.27.1" }, "bin": "./bin/babel-parser.js" }, "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + + "@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@0.7.3", "", {}, "sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg=="], + + "simplify-geojson/concat-stream/readable-stream/string_decoder": ["string_decoder@0.10.31", "", {}, "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="], + + "sucrase/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + + "sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + } +} diff --git a/web/i18next.config.js b/web/i18next.config.js new file mode 100644 index 0000000000000000000000000000000000000000..ca6b4a5f3dcaf39033985efa6d0ce2e96ed36ee2 --- /dev/null +++ b/web/i18next.config.js @@ -0,0 +1,95 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import { defineConfig } from 'i18next-cli'; + +/** @type {import('i18next-cli').I18nextToolkitConfig} */ +export default defineConfig({ + locales: [ + "zh", + "en", + "fr", + "ru" + ], + extract: { + input: [ + "src/**/*.{js,jsx,ts,tsx}" + ], + ignore: [ + "src/i18n/**/*" + ], + output: "src/i18n/locales/{{language}}.json", + ignoredAttributes: [ + "accept", + "align", + "aria-label", + "autoComplete", + "className", + "clipRule", + "color", + "crossOrigin", + "data-index", + "data-name", + "data-testid", + "data-type", + "defaultActiveKey", + "direction", + "editorType", + "field", + "fill", + "fillRule", + "height", + "hoverStyle", + "htmlType", + "id", + "itemKey", + "key", + "keyPrefix", + "layout", + "margin", + "maxHeight", + "mode", + "name", + "overflow", + "placement", + "position", + "rel", + "role", + "rowKey", + "searchPosition", + "selectedStyle", + "shape", + "size", + "style", + "theme", + "trigger", + "uploadTrigger", + "validateStatus", + "value", + "viewBox", + "width" + ], + sort: true, + disablePlurals: false, + removeUnusedKeys: false, + nsSeparator: false, + keySeparator: false, + mergeNamespaces: true + } +}); \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000000000000000000000000000000000000..b1bcf1713e9acbfb5ab5b05f765a83bae4cede0f --- /dev/null +++ b/web/index.html @@ -0,0 +1,22 @@ + + + + + + + + + New API + + + + + + +
+ + + diff --git a/web/jsconfig.json b/web/jsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..170a7cb4cb564d4555c4cb0b7d2afe09086a2eed --- /dev/null +++ b/web/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"] +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000000000000000000000000000000000000..5063c607320936049cfc018321ca7dfe98b15560 --- /dev/null +++ b/web/package.json @@ -0,0 +1,96 @@ +{ + "name": "react-template", + "version": "0.1.0", + "private": true, + "type": "module", + "dependencies": { + "@douyinfe/semi-icons": "^2.63.1", + "@douyinfe/semi-ui": "^2.69.1", + "@lobehub/icons": "^2.0.0", + "@visactor/react-vchart": "~1.8.8", + "@visactor/vchart": "~1.8.8", + "@visactor/vchart-semi-theme": "~1.8.8", + "axios": "1.12.0", + "clsx": "^2.1.1", + "country-flag-icons": "^1.5.19", + "dayjs": "^1.11.11", + "history": "^5.3.0", + "i18next": "^23.16.8", + "i18next-browser-languagedetector": "^7.2.0", + "katex": "^0.16.22", + "lucide-react": "^0.511.0", + "marked": "^4.1.1", + "mermaid": "^11.6.0", + "qrcode.react": "^4.2.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", + "react-fireworks": "^1.0.4", + "react-i18next": "^13.0.0", + "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", + "react-router-dom": "^6.3.0", + "react-telegram-login": "^1.1.2", + "react-toastify": "^9.0.8", + "react-turnstile": "^1.0.5", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "sse.js": "^2.6.0", + "unist-util-visit": "^5.0.0", + "use-debounce": "^10.0.4" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "prettier . --check", + "lint:fix": "prettier . --write", + "eslint": "bunx eslint \"**/*.{js,jsx}\" --cache", + "eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache", + "preview": "vite preview", + "i18n:extract": "bunx i18next-cli extract", + "i18n:status": "bunx i18next-cli status", + "i18n:sync": "bunx i18next-cli sync", + "i18n:lint": "bunx i18next-cli lint" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6", + "@so1ve/prettier-config": "^3.1.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.21", + "eslint": "8.57.0", + "eslint-plugin-header": "^3.1.1", + "eslint-plugin-react-hooks": "^5.2.0", + "postcss": "^8.5.3", + "prettier": "^3.0.0", + "tailwindcss": "^3", + "typescript": "4.4.2", + "vite": "^5.2.0", + "i18next-cli": "^1.10.3" + }, + "prettier": { + "singleQuote": true, + "jsxSingleQuote": true + }, + "proxy": "http://localhost:3000" +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..5731ce76eb1aa05c8aedb727ed02795a05eb6045 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,25 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/public/azure_model_name.png b/web/public/azure_model_name.png new file mode 100644 index 0000000000000000000000000000000000000000..1e3c1162ac2cf9404f12dddbc26df4fd35c748ee --- /dev/null +++ b/web/public/azure_model_name.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84b932315cc40da0bfa209c2ea1997b50a6b29059573a64aa27095bf323769d4 +size 256912 diff --git a/web/public/cover-4.webp b/web/public/cover-4.webp new file mode 100644 index 0000000000000000000000000000000000000000..0e9ecbf0d206c6b1079cc82691beecfb1ae73970 Binary files /dev/null and b/web/public/cover-4.webp differ diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7c83db8ebd7ac077c426c413ad869cdd30a8bf63 --- /dev/null +++ b/web/public/favicon.ico @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a6fdd69f10c17855cdc83856e4fb6680779db14dcc75a1a2716ebdfe4f2f2097 +size 15406 diff --git a/web/public/logo.png b/web/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9fcb6f9ac7b619320ae177f157fb37835fffe628 --- /dev/null +++ b/web/public/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32132a307cad98d7f93b0384de87411f087bb34c148c932dcd5eea92101cbec3 +size 9597 diff --git a/web/public/ratio.png b/web/public/ratio.png new file mode 100644 index 0000000000000000000000000000000000000000..fbda9172364846709cc203139b595f2ab2dbdc19 --- /dev/null +++ b/web/public/ratio.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61cc0ee3c629d7779b7752c152b627a136fac1dab28e1ce2f4c355c195245fe6 +size 143438 diff --git a/web/public/robots.txt b/web/public/robots.txt new file mode 100644 index 0000000000000000000000000000000000000000..e9e57dc4d41b9b46e05112e9f45b7ea6ac0ba15e --- /dev/null +++ b/web/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/web/src/App.jsx b/web/src/App.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b0f281c45224cfdea14c19c951503128cf8522d5 --- /dev/null +++ b/web/src/App.jsx @@ -0,0 +1,355 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React, { lazy, Suspense, useContext, useMemo } from 'react'; +import { Route, Routes, useLocation } from 'react-router-dom'; +import Loading from './components/common/ui/Loading'; +import User from './pages/User'; +import { AuthRedirect, PrivateRoute, AdminRoute } from './helpers'; +import RegisterForm from './components/auth/RegisterForm'; +import LoginForm from './components/auth/LoginForm'; +import NotFound from './pages/NotFound'; +import Forbidden from './pages/Forbidden'; +import Setting from './pages/Setting'; +import { StatusContext } from './context/Status'; + +import PasswordResetForm from './components/auth/PasswordResetForm'; +import PasswordResetConfirm from './components/auth/PasswordResetConfirm'; +import Channel from './pages/Channel'; +import Token from './pages/Token'; +import Redemption from './pages/Redemption'; +import TopUp from './pages/TopUp'; +import Log from './pages/Log'; +import Chat from './pages/Chat'; +import Chat2Link from './pages/Chat2Link'; +import Midjourney from './pages/Midjourney'; +import Pricing from './pages/Pricing'; +import Task from './pages/Task'; +import ModelPage from './pages/Model'; +import Playground from './pages/Playground'; +import OAuth2Callback from './components/auth/OAuth2Callback'; +import PersonalSetting from './components/settings/PersonalSetting'; +import Setup from './pages/Setup'; +import SetupCheck from './components/layout/SetupCheck'; + +const Home = lazy(() => import('./pages/Home')); +const Dashboard = lazy(() => import('./pages/Dashboard')); +const About = lazy(() => import('./pages/About')); +const UserAgreement = lazy(() => import('./pages/UserAgreement')); +const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy')); + +function App() { + const location = useLocation(); + const [statusState] = useContext(StatusContext); + + // 获取模型广场权限配置 + const pricingRequireAuth = useMemo(() => { + const headerNavModulesConfig = statusState?.status?.HeaderNavModules; + if (headerNavModulesConfig) { + try { + const modules = JSON.parse(headerNavModulesConfig); + + // 处理向后兼容性:如果pricing是boolean,默认不需要登录 + if (typeof modules.pricing === 'boolean') { + return false; // 默认不需要登录鉴权 + } + + // 如果是对象格式,使用requireAuth配置 + return modules.pricing?.requireAuth === true; + } catch (error) { + console.error('解析顶栏模块配置失败:', error); + return false; // 默认不需要登录 + } + } + return false; // 默认不需要登录 + }, [statusState?.status?.HeaderNavModules]); + + return ( + + + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + + + } + /> + } key={location.pathname}> + + + + + } + /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> + }> + + + } + /> + } key={location.pathname}> + + + } + /> + + } key={location.pathname}> + + + + } + /> + + } key={location.pathname}> + + + + } + /> + + } key={location.pathname}> + + + + } + /> + + + + } + /> + + } key={location.pathname}> + + + + } + /> + + } key={location.pathname}> + + + + } + /> + + } key={location.pathname}> + + + + } + /> + + } + key={location.pathname} + > + + + + ) : ( + } key={location.pathname}> + + + ) + } + /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> + {/* 方便使用chat2link直接跳转聊天... */} + + } key={location.pathname}> + + + + } + /> + } /> + + + ); +} + +export default App; diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx new file mode 100644 index 0000000000000000000000000000000000000000..489de22762fbb9d5a9064c0e87cbf25e3adeff9d --- /dev/null +++ b/web/src/components/auth/LoginForm.jsx @@ -0,0 +1,910 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { UserContext } from '../../context/User'; +import { + API, + getLogo, + showError, + showInfo, + showSuccess, + updateAPI, + getSystemName, + setUserData, + onGitHubOAuthClicked, + onDiscordOAuthClicked, + onOIDCClicked, + onLinuxDOOAuthClicked, + prepareCredentialRequestOptions, + buildAssertionResult, + isPasskeySupported, +} from '../../helpers'; +import Turnstile from 'react-turnstile'; +import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui'; +import Title from '@douyinfe/semi-ui/lib/es/typography/title'; +import Text from '@douyinfe/semi-ui/lib/es/typography/text'; +import TelegramLoginButton from 'react-telegram-login'; + +import { + IconGithubLogo, + IconMail, + IconLock, + IconKey, +} from '@douyinfe/semi-icons'; +import OIDCIcon from '../common/logo/OIDCIcon'; +import WeChatIcon from '../common/logo/WeChatIcon'; +import LinuxDoIcon from '../common/logo/LinuxDoIcon'; +import TwoFAVerification from './TwoFAVerification'; +import { useTranslation } from 'react-i18next'; +import { SiDiscord }from 'react-icons/si'; + +const LoginForm = () => { + let navigate = useNavigate(); + const { t } = useTranslation(); + const [inputs, setInputs] = useState({ + username: '', + password: '', + wechat_verification_code: '', + }); + const { username, password } = inputs; + const [searchParams, setSearchParams] = useSearchParams(); + const [submitted, setSubmitted] = useState(false); + const [userState, userDispatch] = useContext(UserContext); + const [turnstileEnabled, setTurnstileEnabled] = useState(false); + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); + const [turnstileToken, setTurnstileToken] = useState(''); + const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); + const [showEmailLogin, setShowEmailLogin] = useState(false); + const [wechatLoading, setWechatLoading] = useState(false); + const [githubLoading, setGithubLoading] = useState(false); + const [discordLoading, setDiscordLoading] = useState(false); + const [oidcLoading, setOidcLoading] = useState(false); + const [linuxdoLoading, setLinuxdoLoading] = useState(false); + const [emailLoginLoading, setEmailLoginLoading] = useState(false); + const [loginLoading, setLoginLoading] = useState(false); + const [resetPasswordLoading, setResetPasswordLoading] = useState(false); + const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = + useState(false); + const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); + const [showTwoFA, setShowTwoFA] = useState(false); + const [passkeySupported, setPasskeySupported] = useState(false); + const [passkeyLoading, setPasskeyLoading] = useState(false); + const [agreedToTerms, setAgreedToTerms] = useState(false); + const [hasUserAgreement, setHasUserAgreement] = useState(false); + const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false); + const [githubButtonText, setGithubButtonText] = useState('使用 GitHub 继续'); + const [githubButtonDisabled, setGithubButtonDisabled] = useState(false); + const githubTimeoutRef = useRef(null); + + const logo = getLogo(); + const systemName = getSystemName(); + + let affCode = new URLSearchParams(window.location.search).get('aff'); + if (affCode) { + localStorage.setItem('aff', affCode); + } + + const [status] = useState(() => { + const savedStatus = localStorage.getItem('status'); + return savedStatus ? JSON.parse(savedStatus) : {}; + }); + + useEffect(() => { + if (status.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(status.turnstile_site_key); + } + + // 从 status 获取用户协议和隐私政策的启用状态 + setHasUserAgreement(status.user_agreement_enabled || false); + setHasPrivacyPolicy(status.privacy_policy_enabled || false); + }, [status]); + + useEffect(() => { + isPasskeySupported() + .then(setPasskeySupported) + .catch(() => setPasskeySupported(false)); + + return () => { + if (githubTimeoutRef.current) { + clearTimeout(githubTimeoutRef.current); + } + }; + }, []); + + useEffect(() => { + if (searchParams.get('expired')) { + showError(t('未登录或登录已过期,请重新登录')); + } + }, []); + + const onWeChatLoginClicked = () => { + if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { + showInfo(t('请先阅读并同意用户协议和隐私政策')); + return; + } + setWechatLoading(true); + setShowWeChatLoginModal(true); + setWechatLoading(false); + }; + + const onSubmitWeChatVerificationCode = async () => { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setWechatCodeSubmitLoading(true); + try { + const res = await API.get( + `/api/oauth/wechat?code=${inputs.wechat_verification_code}`, + ); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + setUserData(data); + updateAPI(); + navigate('/'); + showSuccess('登录成功!'); + setShowWeChatLoginModal(false); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } finally { + setWechatCodeSubmitLoading(false); + } + }; + + function handleChange(name, value) { + setInputs((inputs) => ({ ...inputs, [name]: value })); + } + + async function handleSubmit(e) { + if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { + showInfo(t('请先阅读并同意用户协议和隐私政策')); + return; + } + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setSubmitted(true); + setLoginLoading(true); + try { + if (username && password) { + const res = await API.post( + `/api/user/login?turnstile=${turnstileToken}`, + { + username, + password, + }, + ); + const { success, message, data } = res.data; + if (success) { + // 检查是否需要2FA验证 + if (data && data.require_2fa) { + setShowTwoFA(true); + setLoginLoading(false); + return; + } + + userDispatch({ type: 'login', payload: data }); + setUserData(data); + updateAPI(); + showSuccess('登录成功!'); + if (username === 'root' && password === '123456') { + Modal.error({ + title: '您正在使用默认密码!', + content: '请立刻修改默认密码!', + centered: true, + }); + } + navigate('/console'); + } else { + showError(message); + } + } else { + showError('请输入用户名和密码!'); + } + } catch (error) { + showError('登录失败,请重试'); + } finally { + setLoginLoading(false); + } + } + + // 添加Telegram登录处理函数 + const onTelegramLoginClicked = async (response) => { + if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { + showInfo(t('请先阅读并同意用户协议和隐私政策')); + return; + } + const fields = [ + 'id', + 'first_name', + 'last_name', + 'username', + 'photo_url', + 'auth_date', + 'hash', + 'lang', + ]; + const params = {}; + fields.forEach((field) => { + if (response[field]) { + params[field] = response[field]; + } + }); + try { + const res = await API.get(`/api/oauth/telegram/login`, { params }); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + showSuccess('登录成功!'); + setUserData(data); + updateAPI(); + navigate('/'); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } + }; + + // 包装的GitHub登录点击处理 + const handleGitHubClick = () => { + if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { + showInfo(t('请先阅读并同意用户协议和隐私政策')); + return; + } + if (githubButtonDisabled) { + return; + } + setGithubLoading(true); + setGithubButtonDisabled(true); + setGithubButtonText(t('正在跳转 GitHub...')); + if (githubTimeoutRef.current) { + clearTimeout(githubTimeoutRef.current); + } + githubTimeoutRef.current = setTimeout(() => { + setGithubLoading(false); + setGithubButtonText(t('请求超时,请刷新页面后重新发起 GitHub 登录')); + setGithubButtonDisabled(true); + }, 20000); + try { + onGitHubOAuthClicked(status.github_client_id); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setGithubLoading(false), 3000); + } + }; + + // 包装的Discord登录点击处理 + const handleDiscordClick = () => { + if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { + showInfo(t('请先阅读并同意用户协议和隐私政策')); + return; + } + setDiscordLoading(true); + try { + onDiscordOAuthClicked(status.discord_client_id); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setDiscordLoading(false), 3000); + } + }; + + // 包装的OIDC登录点击处理 + const handleOIDCClick = () => { + if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { + showInfo(t('请先阅读并同意用户协议和隐私政策')); + return; + } + setOidcLoading(true); + try { + onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setOidcLoading(false), 3000); + } + }; + + // 包装的LinuxDO登录点击处理 + const handleLinuxDOClick = () => { + if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { + showInfo(t('请先阅读并同意用户协议和隐私政策')); + return; + } + setLinuxdoLoading(true); + try { + onLinuxDOOAuthClicked(status.linuxdo_client_id); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setLinuxdoLoading(false), 3000); + } + }; + + // 包装的邮箱登录选项点击处理 + const handleEmailLoginClick = () => { + setEmailLoginLoading(true); + setShowEmailLogin(true); + setEmailLoginLoading(false); + }; + + const handlePasskeyLogin = async () => { + if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { + showInfo(t('请先阅读并同意用户协议和隐私政策')); + return; + } + if (!passkeySupported) { + showInfo('当前环境无法使用 Passkey 登录'); + return; + } + if (!window.PublicKeyCredential) { + showInfo('当前浏览器不支持 Passkey'); + return; + } + + setPasskeyLoading(true); + try { + const beginRes = await API.post('/api/user/passkey/login/begin'); + const { success, message, data } = beginRes.data; + if (!success) { + showError(message || '无法发起 Passkey 登录'); + return; + } + + const publicKeyOptions = prepareCredentialRequestOptions( + data?.options || data?.publicKey || data, + ); + const assertion = await navigator.credentials.get({ + publicKey: publicKeyOptions, + }); + const payload = buildAssertionResult(assertion); + if (!payload) { + showError('Passkey 验证失败,请重试'); + return; + } + + const finishRes = await API.post( + '/api/user/passkey/login/finish', + payload, + ); + const finish = finishRes.data; + if (finish.success) { + userDispatch({ type: 'login', payload: finish.data }); + setUserData(finish.data); + updateAPI(); + showSuccess('登录成功!'); + navigate('/console'); + } else { + showError(finish.message || 'Passkey 登录失败,请重试'); + } + } catch (error) { + if (error?.name === 'AbortError') { + showInfo('已取消 Passkey 登录'); + } else { + showError('Passkey 登录失败,请重试'); + } + } finally { + setPasskeyLoading(false); + } + }; + + // 包装的重置密码点击处理 + const handleResetPasswordClick = () => { + setResetPasswordLoading(true); + navigate('/reset'); + setResetPasswordLoading(false); + }; + + // 包装的其他登录选项点击处理 + const handleOtherLoginOptionsClick = () => { + setOtherLoginOptionsLoading(true); + setShowEmailLogin(false); + setOtherLoginOptionsLoading(false); + }; + + // 2FA验证成功处理 + const handle2FASuccess = (data) => { + userDispatch({ type: 'login', payload: data }); + setUserData(data); + updateAPI(); + showSuccess('登录成功!'); + navigate('/console'); + }; + + // 返回登录页面 + const handleBackToLogin = () => { + setShowTwoFA(false); + setInputs({ username: '', password: '', wechat_verification_code: '' }); + }; + + const renderOAuthOptions = () => { + return ( +
+
+
+ Logo + + {systemName} + +
+ + +
+ + {t('登 录')} + +
+
+
+ {status.wechat_login && ( + + )} + + {status.github_oauth && ( + + )} + + {status.discord_oauth && ( + + )} + + {status.oidc_enabled && ( + + )} + + {status.linuxdo_oauth && ( + + )} + + {status.telegram_oauth && ( +
+ +
+ )} + + {status.passkey_login && passkeySupported && ( + + )} + + + {t('或')} + + + +
+ + {(hasUserAgreement || hasPrivacyPolicy) && ( +
+ setAgreedToTerms(e.target.checked)} + > + + {t('我已阅读并同意')} + {hasUserAgreement && ( + <> + + {t('用户协议')} + + + )} + {hasUserAgreement && hasPrivacyPolicy && t('和')} + {hasPrivacyPolicy && ( + <> + + {t('隐私政策')} + + + )} + + +
+ )} + + {!status.self_use_mode_enabled && ( +
+ + {t('没有账户?')}{' '} + + {t('注册')} + + +
+ )} +
+
+
+
+ ); + }; + + const renderEmailLoginForm = () => { + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ + {t('登 录')} + +
+
+ {status.passkey_login && passkeySupported && ( + + )} +
+ handleChange('username', value)} + prefix={} + /> + + handleChange('password', value)} + prefix={} + /> + + {(hasUserAgreement || hasPrivacyPolicy) && ( +
+ setAgreedToTerms(e.target.checked)} + > + + {t('我已阅读并同意')} + {hasUserAgreement && ( + <> + + {t('用户协议')} + + + )} + {hasUserAgreement && hasPrivacyPolicy && t('和')} + {hasPrivacyPolicy && ( + <> + + {t('隐私政策')} + + + )} + + +
+ )} + +
+ + + +
+ + + {(status.github_oauth || + status.discord_oauth || + status.oidc_enabled || + status.wechat_login || + status.linuxdo_oauth || + status.telegram_oauth) && ( + <> + + {t('或')} + + +
+ +
+ + )} + + {!status.self_use_mode_enabled && ( +
+ + {t('没有账户?')}{' '} + + {t('注册')} + + +
+ )} +
+
+
+
+ ); + }; + + // 微信登录模态框 + const renderWeChatLoginModal = () => { + return ( + setShowWeChatLoginModal(false)} + okText={t('登录')} + centered={true} + okButtonProps={{ + loading: wechatCodeSubmitLoading, + }} + > +
+ 微信二维码 +
+ +
+

+ {t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')} +

+
+ +
+ + handleChange('wechat_verification_code', value) + } + /> + +
+ ); + }; + + // 2FA验证弹窗 + const render2FAModal = () => { + return ( + +
+ + + +
+ 两步验证 + + } + visible={showTwoFA} + onCancel={handleBackToLogin} + footer={null} + width={450} + centered + > + +
+ ); + }; + + return ( +
+ {/* 背景模糊晕染球 */} +
+
+
+ {showEmailLogin || + !( + status.github_oauth || + status.discord_oauth || + status.oidc_enabled || + status.wechat_login || + status.linuxdo_oauth || + status.telegram_oauth + ) + ? renderEmailLoginForm() + : renderOAuthOptions()} + {renderWeChatLoginModal()} + {render2FAModal()} + + {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} +
+
+ ); +}; + +export default LoginForm; diff --git a/web/src/components/auth/OAuth2Callback.jsx b/web/src/components/auth/OAuth2Callback.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e43e9e0330ed2402e803c185037772481eae8513 --- /dev/null +++ b/web/src/components/auth/OAuth2Callback.jsx @@ -0,0 +1,95 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React, { useContext, useEffect } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + API, + showError, + showSuccess, + updateAPI, + setUserData, +} from '../../helpers'; +import { UserContext } from '../../context/User'; +import Loading from '../common/ui/Loading'; + +const OAuth2Callback = (props) => { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const [, userDispatch] = useContext(UserContext); + const navigate = useNavigate(); + + // 最大重试次数 + const MAX_RETRIES = 3; + + const sendCode = async (code, state, retry = 0) => { + try { + const { data: resData } = await API.get( + `/api/oauth/${props.type}?code=${code}&state=${state}`, + ); + + const { success, message, data } = resData; + + if (!success) { + throw new Error(message || 'OAuth2 callback error'); + } + + if (message === 'bind') { + showSuccess(t('绑定成功!')); + navigate('/console/personal'); + } else { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + setUserData(data); + updateAPI(); + showSuccess(t('登录成功!')); + navigate('/console/token'); + } + } catch (error) { + if (retry < MAX_RETRIES) { + // 递增的退避等待 + await new Promise((resolve) => setTimeout(resolve, (retry + 1) * 2000)); + return sendCode(code, state, retry + 1); + } + + // 重试次数耗尽,提示错误并返回设置页面 + showError(error.message || t('授权失败')); + navigate('/console/personal'); + } + }; + + useEffect(() => { + const code = searchParams.get('code'); + const state = searchParams.get('state'); + + // 参数缺失直接返回 + if (!code) { + showError(t('未获取到授权码')); + navigate('/console/personal'); + return; + } + + sendCode(code, state); + }, []); + + return ; +}; + +export default OAuth2Callback; diff --git a/web/src/components/auth/PasswordResetConfirm.jsx b/web/src/components/auth/PasswordResetConfirm.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9bc37b3ce560d247e2001c10751bcdd4105b79a4 --- /dev/null +++ b/web/src/components/auth/PasswordResetConfirm.jsx @@ -0,0 +1,220 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React, { useEffect, useState } from 'react'; +import { + API, + copy, + showError, + showNotice, + getLogo, + getSystemName, +} from '../../helpers'; +import { useSearchParams, Link } from 'react-router-dom'; +import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui'; +import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons'; +import { useTranslation } from 'react-i18next'; + +const { Text, Title } = Typography; + +const PasswordResetConfirm = () => { + const { t } = useTranslation(); + const [inputs, setInputs] = useState({ + email: '', + token: '', + }); + const { email, token } = inputs; + const isValidResetLink = email && token; + + const [loading, setLoading] = useState(false); + const [disableButton, setDisableButton] = useState(false); + const [countdown, setCountdown] = useState(30); + const [newPassword, setNewPassword] = useState(''); + const [searchParams, setSearchParams] = useSearchParams(); + const [formApi, setFormApi] = useState(null); + + const logo = getLogo(); + const systemName = getSystemName(); + + useEffect(() => { + let token = searchParams.get('token'); + let email = searchParams.get('email'); + setInputs({ + token: token || '', + email: email || '', + }); + if (formApi) { + formApi.setValues({ + email: email || '', + newPassword: newPassword || '', + }); + } + }, [searchParams, newPassword, formApi]); + + useEffect(() => { + let countdownInterval = null; + if (disableButton && countdown > 0) { + countdownInterval = setInterval(() => { + setCountdown(countdown - 1); + }, 1000); + } else if (countdown === 0) { + setDisableButton(false); + setCountdown(30); + } + return () => clearInterval(countdownInterval); + }, [disableButton, countdown]); + + async function handleSubmit(e) { + if (!email || !token) { + showError(t('无效的重置链接,请重新发起密码重置请求')); + return; + } + setDisableButton(true); + setLoading(true); + const res = await API.post(`/api/user/reset`, { + email, + token, + }); + const { success, message } = res.data; + if (success) { + let password = res.data.data; + setNewPassword(password); + await copy(password); + showNotice(`${t('密码已重置并已复制到剪贴板:')} ${password}`); + } else { + showError(message); + } + setLoading(false); + } + + return ( +
+ {/* 背景模糊晕染球 */} +
+
+
+
+
+
+ Logo + + {systemName} + +
+ + +
+ + {t('密码重置确认')} + +
+
+ {!isValidResetLink && ( + + )} +
setFormApi(api)} + initValues={{ + email: email || '', + newPassword: newPassword || '', + }} + className='space-y-4' + > + } + placeholder={email ? '' : t('等待获取邮箱信息...')} + /> + + {newPassword && ( + } + suffix={ + + } + /> + )} + +
+ +
+ + +
+ + + {t('返回登录')} + + +
+
+
+
+
+
+
+ ); +}; + +export default PasswordResetConfirm; diff --git a/web/src/components/auth/PasswordResetForm.jsx b/web/src/components/auth/PasswordResetForm.jsx new file mode 100644 index 0000000000000000000000000000000000000000..92afc2afa43910887ec494a93b223623c3356858 --- /dev/null +++ b/web/src/components/auth/PasswordResetForm.jsx @@ -0,0 +1,193 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React, { useEffect, useState } from 'react'; +import { + API, + getLogo, + showError, + showInfo, + showSuccess, + getSystemName, +} from '../../helpers'; +import Turnstile from 'react-turnstile'; +import { Button, Card, Form, Typography } from '@douyinfe/semi-ui'; +import { IconMail } from '@douyinfe/semi-icons'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +const { Text, Title } = Typography; + +const PasswordResetForm = () => { + const { t } = useTranslation(); + const [inputs, setInputs] = useState({ + email: '', + }); + const { email } = inputs; + + const [loading, setLoading] = useState(false); + const [turnstileEnabled, setTurnstileEnabled] = useState(false); + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); + const [turnstileToken, setTurnstileToken] = useState(''); + const [disableButton, setDisableButton] = useState(false); + const [countdown, setCountdown] = useState(30); + + const logo = getLogo(); + const systemName = getSystemName(); + + useEffect(() => { + let status = localStorage.getItem('status'); + if (status) { + status = JSON.parse(status); + if (status.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(status.turnstile_site_key); + } + } + }, []); + + useEffect(() => { + let countdownInterval = null; + if (disableButton && countdown > 0) { + countdownInterval = setInterval(() => { + setCountdown(countdown - 1); + }, 1000); + } else if (countdown === 0) { + setDisableButton(false); + setCountdown(30); + } + return () => clearInterval(countdownInterval); + }, [disableButton, countdown]); + + function handleChange(value) { + setInputs((inputs) => ({ ...inputs, email: value })); + } + + async function handleSubmit(e) { + if (!email) { + showError(t('请输入邮箱地址')); + return; + } + if (turnstileEnabled && turnstileToken === '') { + showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!')); + return; + } + setDisableButton(true); + setLoading(true); + const res = await API.get( + `/api/reset_password?email=${email}&turnstile=${turnstileToken}`, + ); + const { success, message } = res.data; + if (success) { + showSuccess(t('重置邮件发送成功,请检查邮箱!')); + setInputs({ ...inputs, email: '' }); + } else { + showError(message); + } + setLoading(false); + } + + return ( +
+ {/* 背景模糊晕染球 */} +
+
+
+
+
+
+ Logo + + {systemName} + +
+ + +
+ + {t('密码重置')} + +
+
+
+ } + /> + +
+ +
+ + +
+ + {t('想起来了?')}{' '} + + {t('登录')} + + +
+
+
+ + {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} +
+
+
+
+ ); +}; + +export default PasswordResetForm; diff --git a/web/src/components/auth/RegisterForm.jsx b/web/src/components/auth/RegisterForm.jsx new file mode 100644 index 0000000000000000000000000000000000000000..021a7803d0f8fa111f76766b63255ace896193eb --- /dev/null +++ b/web/src/components/auth/RegisterForm.jsx @@ -0,0 +1,740 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { + API, + getLogo, + showError, + showInfo, + showSuccess, + updateAPI, + getSystemName, + setUserData, + onDiscordOAuthClicked, +} from '../../helpers'; +import Turnstile from 'react-turnstile'; +import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui'; +import Title from '@douyinfe/semi-ui/lib/es/typography/title'; +import Text from '@douyinfe/semi-ui/lib/es/typography/text'; +import { + IconGithubLogo, + IconMail, + IconUser, + IconLock, + IconKey, +} from '@douyinfe/semi-icons'; +import { + onGitHubOAuthClicked, + onLinuxDOOAuthClicked, + onOIDCClicked, +} from '../../helpers'; +import OIDCIcon from '../common/logo/OIDCIcon'; +import LinuxDoIcon from '../common/logo/LinuxDoIcon'; +import WeChatIcon from '../common/logo/WeChatIcon'; +import TelegramLoginButton from 'react-telegram-login/src'; +import { UserContext } from '../../context/User'; +import { useTranslation } from 'react-i18next'; +import { SiDiscord } from 'react-icons/si'; + +const RegisterForm = () => { + let navigate = useNavigate(); + const { t } = useTranslation(); + const [inputs, setInputs] = useState({ + username: '', + password: '', + password2: '', + email: '', + verification_code: '', + wechat_verification_code: '', + }); + const { username, password, password2 } = inputs; + const [userState, userDispatch] = useContext(UserContext); + const [turnstileEnabled, setTurnstileEnabled] = useState(false); + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); + const [turnstileToken, setTurnstileToken] = useState(''); + const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); + const [showEmailRegister, setShowEmailRegister] = useState(false); + const [wechatLoading, setWechatLoading] = useState(false); + const [githubLoading, setGithubLoading] = useState(false); + const [discordLoading, setDiscordLoading] = useState(false); + const [oidcLoading, setOidcLoading] = useState(false); + const [linuxdoLoading, setLinuxdoLoading] = useState(false); + const [emailRegisterLoading, setEmailRegisterLoading] = useState(false); + const [registerLoading, setRegisterLoading] = useState(false); + const [verificationCodeLoading, setVerificationCodeLoading] = useState(false); + const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = + useState(false); + const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); + const [disableButton, setDisableButton] = useState(false); + const [countdown, setCountdown] = useState(30); + const [agreedToTerms, setAgreedToTerms] = useState(false); + const [hasUserAgreement, setHasUserAgreement] = useState(false); + const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false); + const [githubButtonText, setGithubButtonText] = useState('使用 GitHub 继续'); + const [githubButtonDisabled, setGithubButtonDisabled] = useState(false); + const githubTimeoutRef = useRef(null); + + const logo = getLogo(); + const systemName = getSystemName(); + + let affCode = new URLSearchParams(window.location.search).get('aff'); + if (affCode) { + localStorage.setItem('aff', affCode); + } + + const [status] = useState(() => { + const savedStatus = localStorage.getItem('status'); + return savedStatus ? JSON.parse(savedStatus) : {}; + }); + + const [showEmailVerification, setShowEmailVerification] = useState(() => { + return status.email_verification ?? false; + }); + + useEffect(() => { + setShowEmailVerification(status.email_verification); + if (status.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(status.turnstile_site_key); + } + + // 从 status 获取用户协议和隐私政策的启用状态 + setHasUserAgreement(status.user_agreement_enabled || false); + setHasPrivacyPolicy(status.privacy_policy_enabled || false); + }, [status]); + + useEffect(() => { + let countdownInterval = null; + if (disableButton && countdown > 0) { + countdownInterval = setInterval(() => { + setCountdown(countdown - 1); + }, 1000); + } else if (countdown === 0) { + setDisableButton(false); + setCountdown(30); + } + return () => clearInterval(countdownInterval); // Clean up on unmount + }, [disableButton, countdown]); + + useEffect(() => { + return () => { + if (githubTimeoutRef.current) { + clearTimeout(githubTimeoutRef.current); + } + }; + }, []); + + const onWeChatLoginClicked = () => { + setWechatLoading(true); + setShowWeChatLoginModal(true); + setWechatLoading(false); + }; + + const onSubmitWeChatVerificationCode = async () => { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setWechatCodeSubmitLoading(true); + try { + const res = await API.get( + `/api/oauth/wechat?code=${inputs.wechat_verification_code}`, + ); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + setUserData(data); + updateAPI(); + navigate('/'); + showSuccess('登录成功!'); + setShowWeChatLoginModal(false); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } finally { + setWechatCodeSubmitLoading(false); + } + }; + + function handleChange(name, value) { + setInputs((inputs) => ({ ...inputs, [name]: value })); + } + + async function handleSubmit(e) { + if (password.length < 8) { + showInfo('密码长度不得小于 8 位!'); + return; + } + if (password !== password2) { + showInfo('两次输入的密码不一致'); + return; + } + if (username && password) { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setRegisterLoading(true); + try { + if (!affCode) { + affCode = localStorage.getItem('aff'); + } + inputs.aff_code = affCode; + const res = await API.post( + `/api/user/register?turnstile=${turnstileToken}`, + inputs, + ); + const { success, message } = res.data; + if (success) { + navigate('/login'); + showSuccess('注册成功!'); + } else { + showError(message); + } + } catch (error) { + showError('注册失败,请重试'); + } finally { + setRegisterLoading(false); + } + } + } + + const sendVerificationCode = async () => { + if (inputs.email === '') return; + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setVerificationCodeLoading(true); + try { + const res = await API.get( + `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`, + ); + const { success, message } = res.data; + if (success) { + showSuccess('验证码发送成功,请检查你的邮箱!'); + setDisableButton(true); // 发送成功后禁用按钮,开始倒计时 + } else { + showError(message); + } + } catch (error) { + showError('发送验证码失败,请重试'); + } finally { + setVerificationCodeLoading(false); + } + }; + + const handleGitHubClick = () => { + if (githubButtonDisabled) { + return; + } + setGithubLoading(true); + setGithubButtonDisabled(true); + setGithubButtonText(t('正在跳转 GitHub...')); + if (githubTimeoutRef.current) { + clearTimeout(githubTimeoutRef.current); + } + githubTimeoutRef.current = setTimeout(() => { + setGithubLoading(false); + setGithubButtonText(t('请求超时,请刷新页面后重新发起 GitHub 登录')); + setGithubButtonDisabled(true); + }, 20000); + try { + onGitHubOAuthClicked(status.github_client_id); + } finally { + setTimeout(() => setGithubLoading(false), 3000); + } + }; + + const handleDiscordClick = () => { + setDiscordLoading(true); + try { + onDiscordOAuthClicked(status.discord_client_id); + } finally { + setTimeout(() => setDiscordLoading(false), 3000); + } + }; + + const handleOIDCClick = () => { + setOidcLoading(true); + try { + onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id); + } finally { + setTimeout(() => setOidcLoading(false), 3000); + } + }; + + const handleLinuxDOClick = () => { + setLinuxdoLoading(true); + try { + onLinuxDOOAuthClicked(status.linuxdo_client_id); + } finally { + setTimeout(() => setLinuxdoLoading(false), 3000); + } + }; + + const handleEmailRegisterClick = () => { + setEmailRegisterLoading(true); + setShowEmailRegister(true); + setEmailRegisterLoading(false); + }; + + const handleOtherRegisterOptionsClick = () => { + setOtherRegisterOptionsLoading(true); + setShowEmailRegister(false); + setOtherRegisterOptionsLoading(false); + }; + + const onTelegramLoginClicked = async (response) => { + const fields = [ + 'id', + 'first_name', + 'last_name', + 'username', + 'photo_url', + 'auth_date', + 'hash', + 'lang', + ]; + const params = {}; + fields.forEach((field) => { + if (response[field]) { + params[field] = response[field]; + } + }); + try { + const res = await API.get(`/api/oauth/telegram/login`, { params }); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + showSuccess('登录成功!'); + setUserData(data); + updateAPI(); + navigate('/'); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } + }; + + const renderOAuthOptions = () => { + return ( +
+
+
+ Logo + + {systemName} + +
+ + +
+ + {t('注 册')} + +
+
+
+ {status.wechat_login && ( + + )} + + {status.github_oauth && ( + + )} + + {status.discord_oauth && ( + + )} + + {status.oidc_enabled && ( + + )} + + {status.linuxdo_oauth && ( + + )} + + {status.telegram_oauth && ( +
+ +
+ )} + + + {t('或')} + + + +
+ +
+ + {t('已有账户?')}{' '} + + {t('登录')} + + +
+
+
+
+
+ ); + }; + + const renderEmailRegisterForm = () => { + return ( +
+
+
+ Logo + + {systemName} + +
+ + +
+ + {t('注 册')} + +
+
+
+ handleChange('username', value)} + prefix={} + /> + + handleChange('password', value)} + prefix={} + /> + + handleChange('password2', value)} + prefix={} + /> + + {showEmailVerification && ( + <> + handleChange('email', value)} + prefix={} + suffix={ + + } + /> + + handleChange('verification_code', value) + } + prefix={} + /> + + )} + + {(hasUserAgreement || hasPrivacyPolicy) && ( +
+ setAgreedToTerms(e.target.checked)} + > + + {t('我已阅读并同意')} + {hasUserAgreement && ( + <> + + {t('用户协议')} + + + )} + {hasUserAgreement && hasPrivacyPolicy && t('和')} + {hasPrivacyPolicy && ( + <> + + {t('隐私政策')} + + + )} + + +
+ )} + +
+ +
+ + + {(status.github_oauth || + status.discord_oauth || + status.oidc_enabled || + status.wechat_login || + status.linuxdo_oauth || + status.telegram_oauth) && ( + <> + + {t('或')} + + +
+ +
+ + )} + +
+ + {t('已有账户?')}{' '} + + {t('登录')} + + +
+
+
+
+
+ ); + }; + + const renderWeChatLoginModal = () => { + return ( + setShowWeChatLoginModal(false)} + okText={t('登录')} + centered={true} + okButtonProps={{ + loading: wechatCodeSubmitLoading, + }} + > +
+ 微信二维码 +
+ +
+

+ {t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')} +

+
+ +
+ + handleChange('wechat_verification_code', value) + } + /> + +
+ ); + }; + + return ( +
+ {/* 背景模糊晕染球 */} +
+
+
+ {showEmailRegister || + !( + status.github_oauth || + status.discord_oauth || + status.oidc_enabled || + status.wechat_login || + status.linuxdo_oauth || + status.telegram_oauth + ) + ? renderEmailRegisterForm() + : renderOAuthOptions()} + {renderWeChatLoginModal()} + + {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} +
+
+ ); +}; + +export default RegisterForm; diff --git a/web/src/components/auth/TwoFAVerification.jsx b/web/src/components/auth/TwoFAVerification.jsx new file mode 100644 index 0000000000000000000000000000000000000000..626de74363be491e694005105754a5ed89d787a0 --- /dev/null +++ b/web/src/components/auth/TwoFAVerification.jsx @@ -0,0 +1,244 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ +import { API, showError, showSuccess } from '../../helpers'; +import { + Button, + Card, + Divider, + Form, + Input, + Typography, +} from '@douyinfe/semi-ui'; +import React, { useState } from 'react'; + +const { Title, Text, Paragraph } = Typography; + +const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => { + const [loading, setLoading] = useState(false); + const [useBackupCode, setUseBackupCode] = useState(false); + const [verificationCode, setVerificationCode] = useState(''); + + const handleSubmit = async () => { + if (!verificationCode) { + showError('请输入验证码'); + return; + } + // Validate code format + if (useBackupCode && verificationCode.length !== 8) { + showError('备用码必须是8位'); + return; + } else if (!useBackupCode && !/^\d{6}$/.test(verificationCode)) { + showError('验证码必须是6位数字'); + return; + } + + setLoading(true); + try { + const res = await API.post('/api/user/login/2fa', { + code: verificationCode, + }); + + if (res.data.success) { + showSuccess('登录成功'); + // 保存用户信息到本地存储 + localStorage.setItem('user', JSON.stringify(res.data.data)); + if (onSuccess) { + onSuccess(res.data.data); + } + } else { + showError(res.data.message); + } + } catch (error) { + showError('验证失败,请重试'); + } finally { + setLoading(false); + } + }; + + const handleKeyPress = (e) => { + if (e.key === 'Enter') { + handleSubmit(); + } + }; + + if (isModal) { + return ( +
+ + 请输入认证器应用显示的验证码完成登录 + + +
+ + + + + + + +
+ + + {onBack && ( + + )} +
+ +
+ + 提示: +
+ • 验证码每30秒更新一次 +
+ • 如果无法获取验证码,请使用备用码 +
• 每个备用码只能使用一次 +
+
+
+ ); + } + + return ( +
+ +
+ 两步验证 + + 请输入认证器应用显示的验证码完成登录 + +
+ +
+ + + + + + + +
+ + + {onBack && ( + + )} +
+ +
+ + 提示: +
+ • 验证码每30秒更新一次 +
+ • 如果无法获取验证码,请使用备用码 +
• 每个备用码只能使用一次 +
+
+
+
+ ); +}; + +export default TwoFAVerification; diff --git a/web/src/components/common/DocumentRenderer/index.jsx b/web/src/components/common/DocumentRenderer/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..383afc11d99333db0618668edc82d90fe2643768 --- /dev/null +++ b/web/src/components/common/DocumentRenderer/index.jsx @@ -0,0 +1,243 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React, { useEffect, useState } from 'react'; +import { API, showError } from '../../../helpers'; +import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui'; +const { Title } = Typography; +import { + IllustrationConstruction, + IllustrationConstructionDark, +} from '@douyinfe/semi-illustrations'; +import { useTranslation } from 'react-i18next'; +import MarkdownRenderer from '../markdown/MarkdownRenderer'; + +// 检查是否为 URL +const isUrl = (content) => { + try { + new URL(content.trim()); + return true; + } catch { + return false; + } +}; + +// 检查是否为 HTML 内容 +const isHtmlContent = (content) => { + if (!content || typeof content !== 'string') return false; + + // 检查是否包含HTML标签 + const htmlTagRegex = /<\/?[a-z][\s\S]*>/i; + return htmlTagRegex.test(content); +}; + +// 安全地渲染HTML内容 +const sanitizeHtml = (html) => { + // 创建一个临时元素来解析HTML + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + // 提取样式 + const styles = Array.from(tempDiv.querySelectorAll('style')) + .map(style => style.innerHTML) + .join('\n'); + + // 提取body内容,如果没有body标签则使用全部内容 + const bodyContent = tempDiv.querySelector('body'); + const content = bodyContent ? bodyContent.innerHTML : html; + + return { content, styles }; +}; + +/** + * 通用文档渲染组件 + * @param {string} apiEndpoint - API 接口地址 + * @param {string} title - 文档标题 + * @param {string} cacheKey - 本地存储缓存键 + * @param {string} emptyMessage - 空内容时的提示消息 + */ +const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { + const { t } = useTranslation(); + const [content, setContent] = useState(''); + const [loading, setLoading] = useState(true); + const [htmlStyles, setHtmlStyles] = useState(''); + const [processedHtmlContent, setProcessedHtmlContent] = useState(''); + + const loadContent = async () => { + // 先从缓存中获取 + const cachedContent = localStorage.getItem(cacheKey) || ''; + if (cachedContent) { + setContent(cachedContent); + processContent(cachedContent); + setLoading(false); + } + + try { + const res = await API.get(apiEndpoint); + const { success, message, data } = res.data; + if (success && data) { + setContent(data); + processContent(data); + localStorage.setItem(cacheKey, data); + } else { + if (!cachedContent) { + showError(message || emptyMessage); + setContent(''); + } + } + } catch (error) { + if (!cachedContent) { + showError(emptyMessage); + setContent(''); + } + } finally { + setLoading(false); + } + }; + + const processContent = (rawContent) => { + if (isHtmlContent(rawContent)) { + const { content: htmlContent, styles } = sanitizeHtml(rawContent); + setProcessedHtmlContent(htmlContent); + setHtmlStyles(styles); + } else { + setProcessedHtmlContent(''); + setHtmlStyles(''); + } + }; + + useEffect(() => { + loadContent(); + }, []); + + // 处理HTML样式注入 + useEffect(() => { + const styleId = `document-renderer-styles-${cacheKey}`; + + if (htmlStyles) { + let styleEl = document.getElementById(styleId); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = styleId; + styleEl.type = 'text/css'; + document.head.appendChild(styleEl); + } + styleEl.innerHTML = htmlStyles; + } else { + const el = document.getElementById(styleId); + if (el) el.remove(); + } + + return () => { + const el = document.getElementById(styleId); + if (el) el.remove(); + }; + }, [htmlStyles, cacheKey]); + + // 显示加载状态 + if (loading) { + return ( +
+ +
+ ); + } + + // 如果没有内容,显示空状态 + if (!content || content.trim() === '') { + return ( +
+ } + darkModeImage={} + className='p-8' + /> +
+ ); + } + + // 如果是 URL,显示链接卡片 + if (isUrl(content)) { + return ( +
+ +
+ {title} +

+ {t('管理员设置了外部链接,点击下方按钮访问')} +

+ + {t('访问' + title)} + +
+
+
+ ); + } + + // 如果是 HTML 内容,直接渲染 + if (isHtmlContent(content)) { + const { content: htmlContent, styles } = sanitizeHtml(content); + + // 设置样式(如果有的话) + useEffect(() => { + if (styles && styles !== htmlStyles) { + setHtmlStyles(styles); + } + }, [content, styles, htmlStyles]); + + return ( +
+
+
+ {title} +
+
+
+
+ ); + } + + // 其他内容统一使用 Markdown 渲染器 + return ( +
+
+
+ {title} +
+ +
+
+
+
+ ); +}; + +export default DocumentRenderer; \ No newline at end of file diff --git a/web/src/components/common/examples/ChannelKeyViewExample.jsx b/web/src/components/common/examples/ChannelKeyViewExample.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1bb2998b208bce6d6fc127abac47881e84122a2a --- /dev/null +++ b/web/src/components/common/examples/ChannelKeyViewExample.jsx @@ -0,0 +1,113 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Modal } from '@douyinfe/semi-ui'; +import { useSecureVerification } from '../../../hooks/common/useSecureVerification'; +import { createApiCalls } from '../../../services/secureVerification'; +import SecureVerificationModal from '../modals/SecureVerificationModal'; +import ChannelKeyDisplay from '../ui/ChannelKeyDisplay'; + +/** + * 渠道密钥查看组件使用示例 + * 展示如何使用通用安全验证系统 + */ +const ChannelKeyViewExample = ({ channelId }) => { + const { t } = useTranslation(); + const [keyData, setKeyData] = useState(''); + const [showKeyModal, setShowKeyModal] = useState(false); + + // 使用通用安全验证 Hook + const { + isModalVisible, + verificationMethods, + verificationState, + startVerification, + executeVerification, + cancelVerification, + setVerificationCode, + switchVerificationMethod, + } = useSecureVerification({ + onSuccess: (result) => { + // 验证成功后处理结果 + if (result.success && result.data?.key) { + setKeyData(result.data.key); + setShowKeyModal(true); + } + }, + successMessage: t('密钥获取成功'), + }); + + // 开始查看密钥流程 + const handleViewKey = async () => { + const apiCall = createApiCalls.viewChannelKey(channelId); + + await startVerification(apiCall, { + title: t('查看渠道密钥'), + description: t('为了保护账户安全,请验证您的身份。'), + preferredMethod: 'passkey', // 可以指定首选验证方式 + }); + }; + + return ( + <> + {/* 查看密钥按钮 */} + + + {/* 安全验证模态框 */} + + + {/* 密钥显示模态框 */} + setShowKeyModal(false)} + footer={ + + } + width={700} + style={{ maxWidth: '90vw' }} + > + + + + ); +}; + +export default ChannelKeyViewExample; diff --git a/web/src/components/common/logo/LinuxDoIcon.jsx b/web/src/components/common/logo/LinuxDoIcon.jsx new file mode 100644 index 0000000000000000000000000000000000000000..861f19d4f20407f466d5917a3acae4fbb48a3370 --- /dev/null +++ b/web/src/components/common/logo/LinuxDoIcon.jsx @@ -0,0 +1,56 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React from 'react'; +import { Icon } from '@douyinfe/semi-ui'; + +const LinuxDoIcon = (props) => { + function CustomIcon() { + return ( + + + + + + + + ); + } + + return } />; +}; + +export default LinuxDoIcon; diff --git a/web/src/components/common/logo/OIDCIcon.jsx b/web/src/components/common/logo/OIDCIcon.jsx new file mode 100644 index 0000000000000000000000000000000000000000..28d538eb060d975f63dde9d8d6346d7914973c53 --- /dev/null +++ b/web/src/components/common/logo/OIDCIcon.jsx @@ -0,0 +1,57 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React from 'react'; +import { Icon } from '@douyinfe/semi-ui'; + +const OIDCIcon = (props) => { + function CustomIcon() { + return ( + + + + + ); + } + + return } />; +}; + +export default OIDCIcon; diff --git a/web/src/components/common/logo/WeChatIcon.jsx b/web/src/components/common/logo/WeChatIcon.jsx new file mode 100644 index 0000000000000000000000000000000000000000..f9f7057cf9327624c504f11d735addbd49713784 --- /dev/null +++ b/web/src/components/common/logo/WeChatIcon.jsx @@ -0,0 +1,55 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React from 'react'; +import { Icon } from '@douyinfe/semi-ui'; + +const WeChatIcon = () => { + function CustomIcon() { + return ( + + + + + ); + } + + return ( +
+ } /> +
+ ); +}; + +export default WeChatIcon; diff --git a/web/src/components/common/markdown/MarkdownRenderer.jsx b/web/src/components/common/markdown/MarkdownRenderer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..05419f8cc2280f6f36317fb9a62a93de822dfb18 --- /dev/null +++ b/web/src/components/common/markdown/MarkdownRenderer.jsx @@ -0,0 +1,654 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import ReactMarkdown from 'react-markdown'; +import 'katex/dist/katex.min.css'; +import 'highlight.js/styles/github.css'; +import './markdown.css'; +import RemarkMath from 'remark-math'; +import RemarkBreaks from 'remark-breaks'; +import RehypeKatex from 'rehype-katex'; +import RemarkGfm from 'remark-gfm'; +import RehypeHighlight from 'rehype-highlight'; +import { useRef, useState, useEffect, useMemo } from 'react'; +import mermaid from 'mermaid'; +import React from 'react'; +import { useDebouncedCallback } from 'use-debounce'; +import clsx from 'clsx'; +import { Button, Tooltip, Toast } from '@douyinfe/semi-ui'; +import { copy, rehypeSplitWordsIntoSpans } from '../../../helpers'; +import { IconCopy } from '@douyinfe/semi-icons'; +import { useTranslation } from 'react-i18next'; + +mermaid.initialize({ + startOnLoad: false, + theme: 'default', + securityLevel: 'loose', +}); + +export function Mermaid(props) { + const ref = useRef(null); + const [hasError, setHasError] = useState(false); + + useEffect(() => { + if (props.code && ref.current) { + mermaid + .run({ + nodes: [ref.current], + suppressErrors: true, + }) + .catch((e) => { + setHasError(true); + console.error('[Mermaid] ', e.message); + }); + } + }, [props.code]); + + function viewSvgInNewWindow() { + const svg = ref.current?.querySelector('svg'); + if (!svg) return; + const text = new XMLSerializer().serializeToString(svg); + const blob = new Blob([text], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + } + + if (hasError) { + return null; + } + + return ( +
viewSvgInNewWindow()} + > + {props.code} +
+ ); +} + +export function PreCode(props) { + const ref = useRef(null); + const [mermaidCode, setMermaidCode] = useState(''); + const [htmlCode, setHtmlCode] = useState(''); + const { t } = useTranslation(); + + const renderArtifacts = useDebouncedCallback(() => { + if (!ref.current) return; + const mermaidDom = ref.current.querySelector('code.language-mermaid'); + if (mermaidDom) { + setMermaidCode(mermaidDom.innerText); + } + const htmlDom = ref.current.querySelector('code.language-html'); + const refText = ref.current.querySelector('code')?.innerText; + if (htmlDom) { + setHtmlCode(htmlDom.innerText); + } else if ( + refText?.startsWith(' { + if (ref.current) { + const codeElements = ref.current.querySelectorAll('code'); + const wrapLanguages = [ + '', + 'md', + 'markdown', + 'text', + 'txt', + 'plaintext', + 'tex', + 'latex', + ]; + codeElements.forEach((codeElement) => { + let languageClass = codeElement.className.match(/language-(\w+)/); + let name = languageClass ? languageClass[1] : ''; + if (wrapLanguages.includes(name)) { + codeElement.style.whiteSpace = 'pre-wrap'; + } + }); + setTimeout(renderArtifacts, 1); + } + }, []); + + return ( + <> +
+        
+ +
+ {props.children} +
+ {mermaidCode.length > 0 && ( + + )} + {htmlCode.length > 0 && ( +
+
+ HTML预览: +
+
+
+ )} + + ); +} + +function CustomCode(props) { + const ref = useRef(null); + const [collapsed, setCollapsed] = useState(true); + const [showToggle, setShowToggle] = useState(false); + const { t } = useTranslation(); + + useEffect(() => { + if (ref.current) { + const codeHeight = ref.current.scrollHeight; + setShowToggle(codeHeight > 400); + ref.current.scrollTop = ref.current.scrollHeight; + } + }, [props.children]); + + const toggleCollapsed = () => { + setCollapsed((collapsed) => !collapsed); + }; + + const renderShowMoreButton = () => { + if (showToggle && collapsed) { + return ( +
+ +
+ ); + } + return null; + }; + + return ( +
+ + {props.children} + + {renderShowMoreButton()} +
+ ); +} + +function escapeBrackets(text) { + const pattern = + /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g; + return text.replace( + pattern, + (match, codeBlock, squareBracket, roundBracket) => { + if (codeBlock) { + return codeBlock; + } else if (squareBracket) { + return `$$${squareBracket}$$`; + } else if (roundBracket) { + return `$${roundBracket}$`; + } + return match; + }, + ); +} + +function tryWrapHtmlCode(text) { + // 尝试包装HTML代码 + if (text.includes('```')) { + return text; + } + return text + .replace( + /([`]*?)(\w*?)([\n\r]*?)()/g, + (match, quoteStart, lang, newLine, doctype) => { + return !quoteStart ? '\n```html\n' + doctype : match; + }, + ) + .replace( + /(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g, + (match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => { + return !quoteEnd ? bodyEnd + space + htmlEnd + '\n```\n' : match; + }, + ); +} + +function _MarkdownContent(props) { + const { + content, + className, + animated = false, + previousContentLength = 0, + } = props; + + const escapedContent = useMemo(() => { + return tryWrapHtmlCode(escapeBrackets(content)); + }, [content]); + + // 判断是否为用户消息 + const isUserMessage = className && className.includes('user-message'); + + const rehypePluginsBase = useMemo(() => { + const base = [ + RehypeKatex, + [ + RehypeHighlight, + { + detect: false, + ignoreMissing: true, + }, + ], + ]; + if (animated) { + base.push([rehypeSplitWordsIntoSpans, { previousContentLength }]); + } + return base; + }, [animated, previousContentLength]); + + return ( + ( +

+ ), + a: (aProps) => { + const href = aProps.href || ''; + if (/\.(aac|mp3|opus|wav)$/.test(href)) { + return ( +

+ +
+ ); + } + if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) { + return ( + + ); + } + const isInternal = /^\/#/i.test(href); + const target = isInternal ? '_self' : (aProps.target ?? '_blank'); + return ( + { + e.target.style.textDecoration = 'underline'; + }} + onMouseLeave={(e) => { + e.target.style.textDecoration = 'none'; + }} + /> + ); + }, + h1: (props) => ( +

+ ), + h2: (props) => ( +

+ ), + h3: (props) => ( +

+ ), + h4: (props) => ( +

+ ), + h5: (props) => ( +

+ ), + h6: (props) => ( +
+ ), + blockquote: (props) => ( +
+ ), + ul: (props) => ( +
    + ), + ol: (props) => ( +
      + ), + li: (props) => ( +
    1. + ), + table: (props) => ( +
      + + + ), + th: (props) => ( +
      + ), + td: (props) => ( + + ), + }} + > + {escapedContent} + + ); +} + +export const MarkdownContent = React.memo(_MarkdownContent); + +export function MarkdownRenderer(props) { + const { + content, + loading, + fontSize = 14, + fontFamily = 'inherit', + className, + style, + animated = false, + previousContentLength = 0, + ...otherProps + } = props; + + return ( +
      + {loading ? ( +
      +
      + 正在渲染... +
      + ) : ( + + )} +
      + ); +} + +export default MarkdownRenderer; diff --git a/web/src/components/common/markdown/markdown.css b/web/src/components/common/markdown/markdown.css new file mode 100644 index 0000000000000000000000000000000000000000..e1e9e9cb49a6a917d48110283eb317294f9c5560 --- /dev/null +++ b/web/src/components/common/markdown/markdown.css @@ -0,0 +1,449 @@ +/* 基础markdown样式 */ +.markdown-body { + font-family: inherit; + line-height: 1.6; + color: var(--semi-color-text-0); + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; +} + +/* 用户消息样式 - 白色字体适配蓝色背景 */ +.user-message { + color: white !important; +} + +.user-message .markdown-body { + color: white !important; +} + +.user-message h1, +.user-message h2, +.user-message h3, +.user-message h4, +.user-message h5, +.user-message h6 { + color: white !important; +} + +.user-message p { + color: white !important; +} + +.user-message span { + color: white !important; +} + +.user-message div { + color: white !important; +} + +.user-message li { + color: white !important; +} + +.user-message td, +.user-message th { + color: white !important; +} + +.user-message blockquote { + color: white !important; + border-left-color: rgba(255, 255, 255, 0.5) !important; + background-color: rgba(255, 255, 255, 0.1) !important; +} + +.user-message code:not(pre code) { + color: #000 !important; + background-color: rgba(255, 255, 255, 0.9) !important; +} + +.user-message a { + color: #87ceeb !important; + /* 浅蓝色链接 */ +} + +.user-message a:hover { + color: #b0e0e6 !important; + /* hover时更浅的蓝色 */ +} + +/* 表格在用户消息中的样式 */ +.user-message table { + border-color: rgba(255, 255, 255, 0.3) !important; +} + +.user-message th { + background-color: rgba(255, 255, 255, 0.2) !important; + border-color: rgba(255, 255, 255, 0.3) !important; +} + +.user-message td { + border-color: rgba(255, 255, 255, 0.3) !important; +} + +/* 加载动画 */ +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* 代码高亮主题 - 适配Semi Design */ +.hljs { + display: block; + overflow-x: auto; + padding: 0; + background: transparent; + color: var(--semi-color-text-0); +} + +.hljs-comment, +.hljs-quote { + color: var(--semi-color-text-2); + font-style: italic; +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-subst { + color: var(--semi-color-primary); + font-weight: bold; +} + +.hljs-number, +.hljs-literal, +.hljs-variable, +.hljs-template-variable, +.hljs-tag .hljs-attr { + color: var(--semi-color-warning); +} + +.hljs-string, +.hljs-doctag { + color: var(--semi-color-success); +} + +.hljs-title, +.hljs-section, +.hljs-selector-id { + color: var(--semi-color-primary); + font-weight: bold; +} + +.hljs-subst { + font-weight: normal; +} + +.hljs-type, +.hljs-class .hljs-title { + color: var(--semi-color-info); + font-weight: bold; +} + +.hljs-tag, +.hljs-name, +.hljs-attribute { + color: var(--semi-color-primary); + font-weight: normal; +} + +.hljs-regexp, +.hljs-link { + color: var(--semi-color-tertiary); +} + +.hljs-symbol, +.hljs-bullet { + color: var(--semi-color-warning); +} + +.hljs-built_in, +.hljs-builtin-name { + color: var(--semi-color-info); +} + +.hljs-meta { + color: var(--semi-color-text-2); +} + +.hljs-deletion { + background: var(--semi-color-danger-light-default); +} + +.hljs-addition { + background: var(--semi-color-success-light-default); +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} + +/* Mermaid容器样式 */ +.mermaid-container { + transition: all 0.2s ease; +} + +.mermaid-container:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transform: translateY(-1px); +} + +/* 代码块样式增强 */ +pre { + position: relative; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + transition: all 0.2s ease; +} + +pre:hover { + border-color: var(--semi-color-primary) !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +pre:hover .copy-code-button { + opacity: 1 !important; +} + +.copy-code-button { + opacity: 0; + transition: opacity 0.2s ease; + z-index: 10; + pointer-events: auto; +} + +.copy-code-button:hover { + opacity: 1 !important; +} + +.copy-code-button button { + pointer-events: auto !important; + cursor: pointer !important; +} + +/* 确保按钮可点击 */ +.copy-code-button .semi-button { + pointer-events: auto !important; + cursor: pointer !important; + transition: all 0.2s ease; +} + +.copy-code-button .semi-button:hover { + background-color: var(--semi-color-fill-1) !important; + border-color: var(--semi-color-primary) !important; + transform: scale(1.05); +} + +/* 表格响应式 */ +@media (max-width: 768px) { + .markdown-body table { + font-size: 12px; + } + + .markdown-body th, + .markdown-body td { + padding: 6px 8px; + } +} + +/* 数学公式样式 */ +.katex { + font-size: 1em; +} + +.katex-display { + margin: 1em 0; + text-align: center; +} + +/* 链接hover效果 */ +.markdown-body a { + transition: all 0.2s ease; +} + +/* 引用块样式增强 */ +.markdown-body blockquote { + position: relative; +} + +.markdown-body blockquote::before { + content: '"'; + position: absolute; + left: -8px; + top: -8px; + font-size: 24px; + color: var(--semi-color-primary); + opacity: 0.3; +} + +/* 列表样式增强 */ +.markdown-body ul li::marker { + color: var(--semi-color-primary); +} + +.markdown-body ol li::marker { + color: var(--semi-color-primary); + font-weight: bold; +} + +/* 分隔线样式 */ +.markdown-body hr { + border: none; + height: 1px; + background: linear-gradient( + to right, + transparent, + var(--semi-color-border), + transparent + ); + margin: 24px 0; +} + +/* 图片样式 */ +.markdown-body img { + max-width: 100%; + height: auto; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin: 12px 0; +} + +/* 内联代码样式 */ +.markdown-body code:not(pre code) { + background-color: var(--semi-color-fill-1); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9em; + color: var(--semi-color-primary); + border: 1px solid var(--semi-color-border); +} + +/* 标题锚点样式 */ +.markdown-body h1:hover, +.markdown-body h2:hover, +.markdown-body h3:hover, +.markdown-body h4:hover, +.markdown-body h5:hover, +.markdown-body h6:hover { + position: relative; +} + +/* 任务列表样式 */ +.markdown-body input[type='checkbox'] { + margin-right: 8px; + transform: scale(1.1); +} + +.markdown-body li.task-list-item { + list-style: none; + margin-left: -20px; +} + +/* 键盘按键样式 */ +.markdown-body kbd { + background-color: var(--semi-color-fill-0); + border: 1px solid var(--semi-color-border); + border-radius: 3px; + box-shadow: 0 1px 0 var(--semi-color-border); + color: var(--semi-color-text-0); + display: inline-block; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 0.85em; + font-weight: 700; + line-height: 1; + padding: 2px 4px; + white-space: nowrap; +} + +/* 详情折叠样式 */ +.markdown-body details { + border: 1px solid var(--semi-color-border); + border-radius: 6px; + padding: 12px; + margin: 12px 0; +} + +.markdown-body summary { + cursor: pointer; + font-weight: bold; + color: var(--semi-color-primary); + margin-bottom: 8px; +} + +.markdown-body summary:hover { + color: var(--semi-color-primary-hover); +} + +/* 脚注样式 */ +.markdown-body .footnote-ref { + color: var(--semi-color-primary); + text-decoration: none; + font-weight: bold; +} + +.markdown-body .footnote-ref:hover { + text-decoration: underline; +} + +/* 警告块样式 */ +.markdown-body .warning { + background-color: var(--semi-color-warning-light-default); + border-left: 4px solid var(--semi-color-warning); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .info { + background-color: var(--semi-color-info-light-default); + border-left: 4px solid var(--semi-color-info); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .success { + background-color: var(--semi-color-success-light-default); + border-left: 4px solid var(--semi-color-success); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .danger { + background-color: var(--semi-color-danger-light-default); + border-left: 4px solid var(--semi-color-danger); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +@keyframes fade-in { + 0% { + opacity: 0; + transform: translateY(6px) scale(0.98); + filter: blur(3px); + } + 60% { + opacity: 0.85; + filter: blur(0.5px); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } +} + +.animate-fade-in { + animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; + will-change: opacity, transform; +} diff --git a/web/src/components/common/modals/SecureVerificationModal.jsx b/web/src/components/common/modals/SecureVerificationModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..6c61c291d1d67f97f1d681ffdb08cbba4d1463f2 --- /dev/null +++ b/web/src/components/common/modals/SecureVerificationModal.jsx @@ -0,0 +1,322 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Modal, + Button, + Input, + Typography, + Tabs, + TabPane, + Space, + Spin, +} from '@douyinfe/semi-ui'; + +/** + * 通用安全验证模态框组件 + * 配合 useSecureVerification Hook 使用 + * @param {Object} props + * @param {boolean} props.visible - 是否显示模态框 + * @param {Object} props.verificationMethods - 可用的验证方式 + * @param {Object} props.verificationState - 当前验证状态 + * @param {Function} props.onVerify - 验证回调 + * @param {Function} props.onCancel - 取消回调 + * @param {Function} props.onCodeChange - 验证码变化回调 + * @param {Function} props.onMethodSwitch - 验证方式切换回调 + * @param {string} props.title - 模态框标题 + * @param {string} props.description - 验证描述文本 + */ +const SecureVerificationModal = ({ + visible, + verificationMethods, + verificationState, + onVerify, + onCancel, + onCodeChange, + onMethodSwitch, + title, + description, +}) => { + const { t } = useTranslation(); + const [isAnimating, setIsAnimating] = useState(false); + const [verifySuccess, setVerifySuccess] = useState(false); + + const { has2FA, hasPasskey, passkeySupported } = verificationMethods; + const { method, loading, code } = verificationState; + + useEffect(() => { + if (visible) { + setIsAnimating(true); + setVerifySuccess(false); + } else { + setIsAnimating(false); + } + }, [visible]); + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') { + onVerify(method, code); + } + if (e.key === 'Escape' && !loading) { + onCancel(); + } + }; + + // 如果用户没有启用任何验证方式 + if (visible && !has2FA && !hasPasskey) { + return ( + {t('确定')}} + width={500} + style={{ maxWidth: '90vw' }} + > +
      +
      + + + +
      + + {t('需要安全验证')} + + + {t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')} + +
      + + {t('请前往个人设置 → 安全设置进行配置。')} + +
      +
      + ); + } + + return ( + +
      + {/* 描述信息 */} + {description && ( + + {description} + + )} + + {/* 验证方式选择 */} + + {has2FA && ( + +
      +
      + + + + } + style={{ width: '100%' }} + /> +
      + + + {t('从认证器应用中获取验证码,或使用备用码')} + + +
      + + +
      +
      +
      + )} + + {hasPasskey && passkeySupported && ( + +
      +
      +
      + + + +
      + + {t('使用 Passkey 验证')} + + + {t('点击验证按钮,使用您的生物特征或安全密钥')} + +
      + +
      + + +
      +
      +
      + )} +
      +
      +
      + ); +}; + +export default SecureVerificationModal; diff --git a/web/src/components/common/modals/TwoFactorAuthModal.jsx b/web/src/components/common/modals/TwoFactorAuthModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..082e63d797c40deaca318aa6fd184132038d2539 --- /dev/null +++ b/web/src/components/common/modals/TwoFactorAuthModal.jsx @@ -0,0 +1,148 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, Button, Input, Typography } from '@douyinfe/semi-ui'; + +/** + * 可复用的两步验证模态框组件 + * @param {Object} props + * @param {boolean} props.visible - 是否显示模态框 + * @param {string} props.code - 验证码值 + * @param {boolean} props.loading - 是否正在验证 + * @param {Function} props.onCodeChange - 验证码变化回调 + * @param {Function} props.onVerify - 验证回调 + * @param {Function} props.onCancel - 取消回调 + * @param {string} props.title - 模态框标题 + * @param {string} props.description - 验证描述文本 + * @param {string} props.placeholder - 输入框占位文本 + */ +const TwoFactorAuthModal = ({ + visible, + code, + loading, + onCodeChange, + onVerify, + onCancel, + title, + description, + placeholder, +}) => { + const { t } = useTranslation(); + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && code && !loading) { + onVerify(); + } + }; + + return ( + +
      + + + +
      + {title || t('安全验证')} +
      + } + visible={visible} + onCancel={onCancel} + footer={ + <> + + + + } + width={500} + style={{ maxWidth: '90vw' }} + > +
      + {/* 安全提示 */} +
      +
      + + + +
      + + {t('安全验证')} + + + {description || t('为了保护账户安全,请验证您的两步验证码。')} + +
      +
      +
      + + {/* 验证码输入 */} +
      + + {t('验证身份')} + + + + {t( + '支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。', + )} + +
      +
      + + ); +}; + +export default TwoFactorAuthModal; diff --git a/web/src/components/common/ui/CardPro.jsx b/web/src/components/common/ui/CardPro.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2c95f97c750a6d7e7ebf48d4c84871ee99aa0dc0 --- /dev/null +++ b/web/src/components/common/ui/CardPro.jsx @@ -0,0 +1,200 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React, { useState } from 'react'; +import { Card, Divider, Typography, Button } from '@douyinfe/semi-ui'; +import PropTypes from 'prop-types'; +import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { IconEyeOpened, IconEyeClosed } from '@douyinfe/semi-icons'; + +const { Text } = Typography; + +/** + * CardPro 高级卡片组件 + * + * 布局分为6个区域: + * 1. 统计信息区域 (statsArea) + * 2. 描述信息区域 (descriptionArea) + * 3. 类型切换/标签区域 (tabsArea) + * 4. 操作按钮区域 (actionsArea) + * 5. 搜索表单区域 (searchArea) + * 6. 分页区域 (paginationArea) - 固定在卡片底部 + * + * 支持三种布局类型: + * - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单 + * - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单 + * - type3: 复杂型 (如ChannelsTable) - 描述信息 + 类型切换 + 操作按钮 + 搜索表单 + */ +const CardPro = ({ + type = 'type1', + className = '', + children, + // 各个区域的内容 + statsArea, + descriptionArea, + tabsArea, + actionsArea, + searchArea, + paginationArea, // 新增分页区域 + // 卡片属性 + shadows = '', + bordered = true, + // 自定义样式 + style, + // 国际化函数 + t = (key) => key, + ...props +}) => { + const isMobile = useIsMobile(); + const [showMobileActions, setShowMobileActions] = useState(false); + + const toggleMobileActions = () => { + setShowMobileActions(!showMobileActions); + }; + + const hasMobileHideableContent = actionsArea || searchArea; + + const renderHeader = () => { + const hasContent = + statsArea || descriptionArea || tabsArea || actionsArea || searchArea; + if (!hasContent) return null; + + return ( +
      + {/* 统计信息区域 - 用于type2 */} + {type === 'type2' && statsArea && <>{statsArea}} + + {/* 描述信息区域 - 用于type1和type3 */} + {(type === 'type1' || type === 'type3') && descriptionArea && ( + <>{descriptionArea} + )} + + {/* 第一个分隔线 - 在描述信息或统计信息后面 */} + {((type === 'type1' || type === 'type3') && descriptionArea) || + (type === 'type2' && statsArea) ? ( + + ) : null} + + {/* 类型切换/标签区域 - 主要用于type3 */} + {type === 'type3' && tabsArea && <>{tabsArea}} + + {/* 移动端操作切换按钮 */} + {isMobile && hasMobileHideableContent && ( + <> +
      + +
      + + )} + + {/* 操作按钮和搜索表单的容器 */} +
      + {/* 操作按钮区域 - 用于type1和type3 */} + {(type === 'type1' || type === 'type3') && + actionsArea && + (Array.isArray(actionsArea) ? ( + actionsArea.map((area, idx) => ( + + {idx !== 0 && } +
      {area}
      +
      + )) + ) : ( +
      {actionsArea}
      + ))} + + {/* 当同时存在操作区和搜索区时,插入分隔线 */} + {actionsArea && searchArea && } + + {/* 搜索表单区域 - 所有类型都可能有 */} + {searchArea &&
      {searchArea}
      } +
      +
      + ); + }; + + const headerContent = renderHeader(); + + // 渲染分页区域 + const renderFooter = () => { + if (!paginationArea) return null; + + return ( +
      + {paginationArea} +
      + ); + }; + + const footerContent = renderFooter(); + + return ( + + {children} + + ); +}; + +CardPro.propTypes = { + // 布局类型 + type: PropTypes.oneOf(['type1', 'type2', 'type3']), + // 样式相关 + className: PropTypes.string, + style: PropTypes.object, + shadows: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + bordered: PropTypes.bool, + // 内容区域 + statsArea: PropTypes.node, + descriptionArea: PropTypes.node, + tabsArea: PropTypes.node, + actionsArea: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.arrayOf(PropTypes.node), + ]), + searchArea: PropTypes.node, + paginationArea: PropTypes.node, + // 表格内容 + children: PropTypes.node, + // 国际化函数 + t: PropTypes.func, +}; + +export default CardPro; diff --git a/web/src/components/common/ui/CardTable.jsx b/web/src/components/common/ui/CardTable.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8a331d07ec0de38b834de49da118c41bc2d1e6c5 --- /dev/null +++ b/web/src/components/common/ui/CardTable.jsx @@ -0,0 +1,242 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React, { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Table, + Card, + Skeleton, + Pagination, + Empty, + Button, + Collapsible, +} from '@douyinfe/semi-ui'; +import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; +import PropTypes from 'prop-types'; +import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; + +/** + * CardTable 响应式表格组件 + * + * 在桌面端渲染 Semi-UI 的 Table 组件,在移动端则将每一行数据渲染成 Card 形式。 + * 该组件与 Table 组件的大部分 API 保持一致,只需将原 Table 换成 CardTable 即可。 + */ +const CardTable = ({ + columns = [], + dataSource = [], + loading = false, + rowKey = 'key', + hidePagination = false, + ...tableProps +}) => { + const isMobile = useIsMobile(); + const { t } = useTranslation(); + + const showSkeleton = useMinimumLoadingTime(loading); + + const getRowKey = (record, index) => { + if (typeof rowKey === 'function') return rowKey(record); + return record[rowKey] !== undefined ? record[rowKey] : index; + }; + + if (!isMobile) { + const finalTableProps = hidePagination + ? { ...tableProps, pagination: false } + : tableProps; + + return ( + + ); + } + + if (showSkeleton) { + const visibleCols = columns.filter((col) => { + if (tableProps?.visibleColumns && col.key) { + return tableProps.visibleColumns[col.key]; + } + return true; + }); + + const renderSkeletonCard = (key) => { + const placeholder = ( +
      + {visibleCols.map((col, idx) => { + if (!col.title) { + return ( +
      + +
      + ); + } + + return ( +
      + + +
      + ); + })} +
      + ); + + return ( + + + + ); + }; + + return ( +
      + {[1, 2, 3].map((i) => renderSkeletonCard(i))} +
      + ); + } + + const isEmpty = !showSkeleton && (!dataSource || dataSource.length === 0); + + const MobileRowCard = ({ record, index }) => { + const [showDetails, setShowDetails] = useState(false); + const rowKeyVal = getRowKey(record, index); + + const hasDetails = + tableProps.expandedRowRender && + (!tableProps.rowExpandable || tableProps.rowExpandable(record)); + + return ( + + {columns.map((col, colIdx) => { + if ( + tableProps?.visibleColumns && + !tableProps.visibleColumns[col.key] + ) { + return null; + } + + const title = col.title; + const cellContent = col.render + ? col.render(record[col.dataIndex], record, index) + : record[col.dataIndex]; + + if (!title) { + return ( +
      + {cellContent} +
      + ); + } + + return ( +
      + + {title} + +
      + {cellContent !== undefined && cellContent !== null + ? cellContent + : '-'} +
      +
      + ); + })} + + {hasDetails && ( + <> + + +
      + {tableProps.expandedRowRender(record, index)} +
      +
      + + )} +
      + ); + }; + + if (isEmpty) { + if (tableProps.empty) return tableProps.empty; + return ( +
      + +
      + ); + } + + return ( +
      + {dataSource.map((record, index) => ( + + ))} + {!hidePagination && tableProps.pagination && dataSource.length > 0 && ( +
      + +
      + )} +
      + ); +}; + +CardTable.propTypes = { + columns: PropTypes.array.isRequired, + dataSource: PropTypes.array, + loading: PropTypes.bool, + rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), + hidePagination: PropTypes.bool, +}; + +export default CardTable; diff --git a/web/src/components/common/ui/ChannelKeyDisplay.jsx b/web/src/components/common/ui/ChannelKeyDisplay.jsx new file mode 100644 index 0000000000000000000000000000000000000000..79aa3eec708472d0be1dda8b2d1123b146429d15 --- /dev/null +++ b/web/src/components/common/ui/ChannelKeyDisplay.jsx @@ -0,0 +1,280 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card, Button, Typography, Tag } from '@douyinfe/semi-ui'; +import { copy, showSuccess } from '../../../helpers'; + +/** + * 解析密钥数据,支持多种格式 + * @param {string} keyData - 密钥数据 + * @param {Function} t - 翻译函数 + * @returns {Array} 解析后的密钥数组 + */ +const parseChannelKeys = (keyData, t) => { + if (!keyData) return []; + + const trimmed = keyData.trim(); + + // 检查是否是JSON数组格式(如Vertex AI) + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed.map((item, index) => ({ + id: index, + content: + typeof item === 'string' ? item : JSON.stringify(item, null, 2), + type: typeof item === 'string' ? 'text' : 'json', + label: `${t('密钥')} ${index + 1}`, + })); + } + } catch (e) { + // 如果解析失败,按普通文本处理 + console.warn('Failed to parse JSON keys:', e); + } + } + + // 检查是否是多行密钥(按换行符分割) + const lines = trimmed.split('\n').filter((line) => line.trim()); + if (lines.length > 1) { + return lines.map((line, index) => ({ + id: index, + content: line.trim(), + type: 'text', + label: `${t('密钥')} ${index + 1}`, + })); + } + + // 单个密钥 + return [ + { + id: 0, + content: trimmed, + type: trimmed.startsWith('{') ? 'json' : 'text', + label: t('密钥'), + }, + ]; +}; + +/** + * 可复用的密钥显示组件 + * @param {Object} props + * @param {string} props.keyData - 密钥数据 + * @param {boolean} props.showSuccessIcon - 是否显示成功图标 + * @param {string} props.successText - 成功文本 + * @param {boolean} props.showWarning - 是否显示安全警告 + * @param {string} props.warningText - 警告文本 + */ +const ChannelKeyDisplay = ({ + keyData, + showSuccessIcon = true, + successText, + showWarning = true, + warningText, +}) => { + const { t } = useTranslation(); + + const parsedKeys = parseChannelKeys(keyData, t); + const isMultipleKeys = parsedKeys.length > 1; + + const handleCopyAll = () => { + copy(keyData); + showSuccess(t('所有密钥已复制到剪贴板')); + }; + + const handleCopyKey = (content) => { + copy(content); + showSuccess(t('密钥已复制到剪贴板')); + }; + + return ( +
      + {/* 成功状态 */} + {showSuccessIcon && ( +
      + + + + + {successText || t('验证成功')} + +
      + )} + + {/* 密钥内容 */} +
      +
      + + {isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')} + + {isMultipleKeys && ( +
      + + {t('共 {{count}} 个密钥', { count: parsedKeys.length })} + + +
      + )} +
      + +
      + {parsedKeys.map((keyItem) => ( + +
      +
      + + {keyItem.label} + +
      + {keyItem.type === 'json' && ( + + {t('JSON')} + + )} + +
      +
      + +
      + + {keyItem.content} + +
      + + {keyItem.type === 'json' && ( + + {t('JSON格式密钥,请确保格式正确')} + + )} +
      +
      + ))} +
      + + {isMultipleKeys && ( +
      + + + + + {t( + '检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。', + )} + +
      + )} +
      + + {/* 安全警告 */} + {showWarning && ( +
      +
      + + + +
      + + {t('安全提醒')} + + + {warningText || + t( + '请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。', + )} + +
      +
      +
      + )} +
      + ); +}; + +export default ChannelKeyDisplay; diff --git a/web/src/components/common/ui/CompactModeToggle.jsx b/web/src/components/common/ui/CompactModeToggle.jsx new file mode 100644 index 0000000000000000000000000000000000000000..40da0abc0058a504682d97528aa416bafaf2955c --- /dev/null +++ b/web/src/components/common/ui/CompactModeToggle.jsx @@ -0,0 +1,68 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React from 'react'; +import { Button } from '@douyinfe/semi-ui'; +import PropTypes from 'prop-types'; +import { useIsMobile } from '../../../hooks/common/useIsMobile'; + +/** + * 紧凑模式切换按钮组件 + * 用于在自适应列表和紧凑列表之间切换 + * 在移动端时自动隐藏,因为移动端使用"显示操作项"按钮来控制内容显示 + */ +const CompactModeToggle = ({ + compactMode, + setCompactMode, + t, + size = 'small', + type = 'tertiary', + className = '', + ...props +}) => { + const isMobile = useIsMobile(); + + // 在移动端隐藏紧凑列表切换按钮 + if (isMobile) { + return null; + } + + return ( + + ); +}; + +CompactModeToggle.propTypes = { + compactMode: PropTypes.bool.isRequired, + setCompactMode: PropTypes.func.isRequired, + t: PropTypes.func.isRequired, + size: PropTypes.string, + type: PropTypes.string, + className: PropTypes.string, +}; + +export default CompactModeToggle; diff --git a/web/src/components/common/ui/JSONEditor.jsx b/web/src/components/common/ui/JSONEditor.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d897538728c575807a5c1a2c5d068f9c4106e8df --- /dev/null +++ b/web/src/components/common/ui/JSONEditor.jsx @@ -0,0 +1,714 @@ +/* +Copyright (C) 2025 QuantumNous + +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 support@quantumnous.com +*/ + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + Form, + Typography, + Banner, + Tabs, + TabPane, + Card, + Input, + InputNumber, + Switch, + TextArea, + Row, + Col, + Divider, + Tooltip, +} from '@douyinfe/semi-ui'; +import { IconPlus, IconDelete, IconAlertTriangle } from '@douyinfe/semi-icons'; + +const { Text } = Typography; + +// 唯一 ID 生成器,确保在组件生命周期内稳定且递增 +const generateUniqueId = (() => { + let counter = 0; + return () => `kv_${counter++}`; +})(); + +const JSONEditor = ({ + value = '', + onChange, + field, + label, + placeholder, + extraText, + extraFooter, + showClear = true, + template, + templateLabel, + editorType = 'keyValue', + rules = [], + formApi = null, + ...props +}) => { + const { t } = useTranslation(); + + // 将对象转换为键值对数组(包含唯一ID) + const objectToKeyValueArray = useCallback((obj, prevPairs = []) => { + if (!obj || typeof obj !== 'object') return []; + + const entries = Object.entries(obj); + return entries.map(([key, value], index) => { + // 如果上一次转换后同位置的键一致,则沿用其 id,保持 React key 稳定 + const prev = prevPairs[index]; + const shouldReuseId = prev && prev.key === key; + return { + id: shouldReuseId ? prev.id : generateUniqueId(), + key, + value, + }; + }); + }, []); + + // 将键值对数组转换为对象(重复键时后面的会覆盖前面的) + const keyValueArrayToObject = useCallback((arr) => { + const result = {}; + arr.forEach((item) => { + if (item.key) { + result[item.key] = item.value; + } + }); + return result; + }, []); + + // 初始化键值对数组 + const [keyValuePairs, setKeyValuePairs] = useState(() => { + if (typeof value === 'string' && value.trim()) { + try { + const parsed = JSON.parse(value); + return objectToKeyValueArray(parsed); + } catch (error) { + return []; + } + } + if (typeof value === 'object' && value !== null) { + return objectToKeyValueArray(value); + } + return []; + }); + + // 手动模式下的本地文本缓冲 + const [manualText, setManualText] = useState(() => { + if (typeof value === 'string') return value; + if (value && typeof value === 'object') + return JSON.stringify(value, null, 2); + return ''; + }); + + // 根据键数量决定默认编辑模式 + const [editMode, setEditMode] = useState(() => { + if (typeof value === 'string' && value.trim()) { + try { + const parsed = JSON.parse(value); + const keyCount = Object.keys(parsed).length; + return keyCount > 10 ? 'manual' : 'visual'; + } catch (error) { + return 'manual'; + } + } + return 'visual'; + }); + + const [jsonError, setJsonError] = useState(''); + + // 计算重复的键 + const duplicateKeys = useMemo(() => { + const keyCount = {}; + const duplicates = new Set(); + + keyValuePairs.forEach((pair) => { + if (pair.key) { + keyCount[pair.key] = (keyCount[pair.key] || 0) + 1; + if (keyCount[pair.key] > 1) { + duplicates.add(pair.key); + } + } + }); + + return duplicates; + }, [keyValuePairs]); + + // 数据同步 - 当value变化时更新键值对数组 + useEffect(() => { + try { + let parsed = {}; + if (typeof value === 'string' && value.trim()) { + parsed = JSON.parse(value); + } else if (typeof value === 'object' && value !== null) { + parsed = value; + } + + // 只在外部值真正改变时更新,避免循环更新 + const currentObj = keyValueArrayToObject(keyValuePairs); + if (JSON.stringify(parsed) !== JSON.stringify(currentObj)) { + setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); + } + setJsonError(''); + } catch (error) { + console.log('JSON解析失败:', error.message); + setJsonError(error.message); + } + }, [value]); + + // 外部 value 变化时,若不在手动模式,则同步手动文本 + useEffect(() => { + if (editMode !== 'manual') { + if (typeof value === 'string') setManualText(value); + else if (value && typeof value === 'object') + setManualText(JSON.stringify(value, null, 2)); + else setManualText(''); + } + }, [value, editMode]); + + // 处理可视化编辑的数据变化 + const handleVisualChange = useCallback( + (newPairs) => { + setKeyValuePairs(newPairs); + const jsonObject = keyValueArrayToObject(newPairs); + const jsonString = + Object.keys(jsonObject).length === 0 + ? '' + : JSON.stringify(jsonObject, null, 2); + + setJsonError(''); + + // 通过formApi设置值 + if (formApi && field) { + formApi.setValue(field, jsonString); + } + + onChange?.(jsonString); + }, + [onChange, formApi, field, keyValueArrayToObject], + ); + + // 处理手动编辑的数据变化 + const handleManualChange = useCallback( + (newValue) => { + setManualText(newValue); + if (newValue && newValue.trim()) { + try { + const parsed = JSON.parse(newValue); + setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); + setJsonError(''); + onChange?.(newValue); + } catch (error) { + setJsonError(error.message); + } + } else { + setKeyValuePairs([]); + setJsonError(''); + onChange?.(''); + } + }, + [onChange, objectToKeyValueArray, keyValuePairs], + ); + + // 切换编辑模式 + const toggleEditMode = useCallback(() => { + if (editMode === 'visual') { + const jsonObject = keyValueArrayToObject(keyValuePairs); + setManualText( + Object.keys(jsonObject).length === 0 + ? '' + : JSON.stringify(jsonObject, null, 2), + ); + setEditMode('manual'); + } else { + try { + let parsed = {}; + if (manualText && manualText.trim()) { + parsed = JSON.parse(manualText); + } else if (typeof value === 'string' && value.trim()) { + parsed = JSON.parse(value); + } else if (typeof value === 'object' && value !== null) { + parsed = value; + } + setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); + setJsonError(''); + setEditMode('visual'); + } catch (error) { + setJsonError(error.message); + return; + } + } + }, [ + editMode, + value, + manualText, + keyValuePairs, + keyValueArrayToObject, + objectToKeyValueArray, + ]); + + // 添加键值对 + const addKeyValue = useCallback(() => { + const newPairs = [...keyValuePairs]; + const existingKeys = newPairs.map((p) => p.key); + let counter = 1; + let newKey = `field_${counter}`; + while (existingKeys.includes(newKey)) { + counter += 1; + newKey = `field_${counter}`; + } + newPairs.push({ + id: generateUniqueId(), + key: newKey, + value: '', + }); + handleVisualChange(newPairs); + }, [keyValuePairs, handleVisualChange]); + + // 删除键值对 + const removeKeyValue = useCallback( + (id) => { + const newPairs = keyValuePairs.filter((pair) => pair.id !== id); + handleVisualChange(newPairs); + }, + [keyValuePairs, handleVisualChange], + ); + + // 更新键名 + const updateKey = useCallback( + (id, newKey) => { + const newPairs = keyValuePairs.map((pair) => + pair.id === id ? { ...pair, key: newKey } : pair, + ); + handleVisualChange(newPairs); + }, + [keyValuePairs, handleVisualChange], + ); + + // 更新值 + const updateValue = useCallback( + (id, newValue) => { + const newPairs = keyValuePairs.map((pair) => + pair.id === id ? { ...pair, value: newValue } : pair, + ); + handleVisualChange(newPairs); + }, + [keyValuePairs, handleVisualChange], + ); + + // 填入模板 + const fillTemplate = useCallback(() => { + if (template) { + const templateString = JSON.stringify(template, null, 2); + + if (formApi && field) { + formApi.setValue(field, templateString); + } + + setManualText(templateString); + setKeyValuePairs(objectToKeyValueArray(template, keyValuePairs)); + onChange?.(templateString); + setJsonError(''); + } + }, [ + template, + onChange, + formApi, + field, + objectToKeyValueArray, + keyValuePairs, + ]); + + // 渲染值输入控件(支持嵌套) + const renderValueInput = (pairId, value) => { + const valueType = typeof value; + + if (valueType === 'boolean') { + return ( +
      + updateValue(pairId, newValue)} + /> + + {value ? t('true') : t('false')} + +
      + ); + } + + if (valueType === 'number') { + return ( + updateValue(pairId, newValue)} + style={{ width: '100%' }} + placeholder={t('输入数字')} + /> + ); + } + + if (valueType === 'object' && value !== null) { + // 简化嵌套对象的处理,使用TextArea + return ( +