Compare commits

...

775 Commits

Author SHA1 Message Date
Apple\Apple
39c841e600 feat(architecture): Core+Plugin 2025-10-13 02:02:11 +08:00
CaIon
ede47ef014 feat: support free model setting 2025-10-12 13:31:03 +08:00
Seefs
6c7795238f Merge pull request #2023 from seefs001/fix/version
fix: version
2025-10-12 13:05:51 +08:00
Seefs
0baacb2686 fix: version 2025-10-12 13:05:13 +08:00
Seefs
c5aaee9f2f Merge pull request #2022 from seefs001/fix/ignore_ghcr
ignore ghcr
2025-10-12 12:40:18 +08:00
Seefs
1987c7e16c ignore ghcr 2025-10-12 12:38:44 +08:00
CaIon
c13bb67360 fix: mask sensitive information in error messages and refine task retrieval query 2025-10-12 12:25:11 +08:00
Seefs
77f8e51b56 Merge pull request #2018 from feitianbubu/pr/add-jimeng-vidu-openai-sdk
即梦和vidu支持 OpenAI sdk生成和查询视频
2025-10-11 20:40:47 +08:00
feitianbubu
e08f799994 feat: add vidu use openai sdk 2025-10-11 17:11:44 +08:00
feitianbubu
cc41ac63bf fix: kling create video via openai sdk 2025-10-11 17:11:44 +08:00
feitianbubu
4e0f4b207d refactor: add openaiVideo and method 2025-10-11 17:05:36 +08:00
feitianbubu
8a7033e5a3 feat: add jimeng use openai sdk 2025-10-11 17:05:33 +08:00
Seefs
7cb60c7d83 Merge pull request #1991 from Sacode/i18n
feat: complete English, French and Russian translation and add i18n configuration
2025-10-11 15:56:55 +08:00
Seefs
e1c7a4f41f format: package name -> github.com/QuantumNous/new-api (#2017) 2025-10-11 15:30:09 +08:00
Seefs
2d3fd5634a Merge pull request #2016 from seefs001/fix/ci-env
fix env GHCR_REPOSITORY
2025-10-11 14:19:42 +08:00
Seefs
5cfa64838c fix env GHCR_REPOSITORY 2025-10-11 14:18:09 +08:00
Seefs
55afd5cbdf feat: matrix ci (#2014) 2025-10-11 14:08:32 +08:00
Seefs
65920983cc Alpha CI (#2011) 2025-10-11 13:24:04 +08:00
Dmitriy Safonov
ced72951e4 feat: add French translation glossary for consistent terminology
Add comprehensive French translation glossary document to standardize key project terminology. The glossary includes translations for core concepts, model-related terms, user management, recharge & redemption, channel management, and security terms. This ensures consistency and accuracy in French translations across the project, with specific guidance on technical terms and contextual usage.
2025-10-11 08:16:13 +03:00
CaIon
0ff98b1dc1 feat: update Go version in CI configuration and add release workflow for multi-platform builds 2025-10-11 12:44:09 +08:00
CaIon
5d4a0757f7 fix: ensure error message is set when it is empty in error handling #1972 2025-10-11 12:12:41 +08:00
CaIon
07b099006c feat: add logging for model details and enhance action assignment in relay tasks 2025-10-11 11:56:44 +08:00
CaIon
5fbf860020 feat: enhance multipart validation with additional fields for model, seconds, and size 2025-10-11 11:33:59 +08:00
Calcium-Ion
eab768b4a0 Merge pull request #2006 from xyfacai/feat/sora-price
feat: sora 增加参数校验与计费
2025-10-11 11:22:08 +08:00
Calcium-Ion
1031f1ddf0 Merge pull request #2004 from feitianbubu/pr/openai-sdk-kling
支持可灵使用openai sdk生成视频
2025-10-11 11:05:11 +08:00
Dmitriy Safonov
63c01016e4 feat(i18n): add French pluralization support and complete translations
- Add pluralization rules for French locale using _one, _many, _other suffixes
- Complete missing French translations for web search, file search, and key count strings
- Add translations for import/export configuration functionality
- Fill in missing translations for UI elements like ID, IP, expand, and various status messages
- Improve French localization coverage for better user experience
2025-10-11 04:35:22 +03:00
Dmitriy Safonov
5810c05dab feat(i18n): enable pluralization for count-based translations
Enable i18next pluralization by setting disablePlurals to false and update multiple translation keys to use _one/_other suffixes for proper singular/plural handling. This improves localization accuracy for count-dependent strings like "X keys", "X models", and "X times".
2025-10-11 04:35:22 +03:00
Dmitriy Safonov
c29eef9b15 feat(i18n): reorganize and expand ignored attributes list
Reordered the ignoredAttributes array in i18next.config.js alphabetically and added several new attributes to prevent unnecessary translation extraction. This improves the localization process by excluding more non-translatable properties like accept, align, autoComplete, clipRule, crossOrigin, and others.
2025-10-11 04:35:22 +03:00
Dmitriy Safonov
b472f9c5b5 refactor(i18n): convert config from TypeScript to JavaScript
Converted i18next.config.ts to i18next.config.js and added AGPL license header. The change simplifies the build process by removing TypeScript compilation for this configuration file while maintaining the same functionality.
2025-10-11 04:35:22 +03:00
Dmitriy Safonov
a34d8f586e feat: move i18next-cli to devDependencies
Relocated i18next-cli from dependencies to devDependencies as it's only needed for development tasks like translation management, not for runtime functionality.
2025-10-11 04:35:22 +03:00
Dmitriy Safonov
c7661167cf feat: add i18n configuration and expand translation glossary
Add comprehensive i18next configuration for internationalization support with Chinese, English, and French locales. Configure extraction settings and ignore patterns for React components. Expand translation glossary with security and billing terminology including Two-Factor Authentication, 2FA, and pricing multiplier terms.
2025-10-11 04:35:22 +03:00
feitianbubu
5f36e32821 feat: add openai sdk create 2025-10-11 02:44:01 +08:00
feitianbubu
11e8e4e7a6 feat: add openai sdk for kling 2025-10-11 02:43:56 +08:00
feitianbubu
35422b316d refactor: openAI video use OpenAIVideoConverter 2025-10-11 02:43:43 +08:00
Seefs
df0ae9294d Merge pull request #2002 from qixing-jk/fix/dynamic-frequency-updates
fix(channel): handle dynamic frequency updates
2025-10-11 01:01:23 +08:00
anime
57e5d67f86 fix(channel): move log statement after sleep in auto-test loop 2025-10-11 00:59:13 +08:00
anime
7351480365 fix(channel): handle dynamic frequency updates 2025-10-11 00:53:26 +08:00
anime
e19e904179 fix(channel): handle dynamic frequency updates
- replace infinite sleep loop with time.Ticker to avoid goroutine leaks
- add immediate initial test execution before ticker starts
- implement frequency change detection and ticker recreation
- ensure proper ticker cleanup when loop exits or feature disabled
2025-10-11 00:34:15 +08:00
Xyfacai
a54baf4998 feat: sora 增加参数校验与计费 2025-10-10 23:56:36 +08:00
Seefs
721357b4a4 Merge pull request #2000 from feitianbubu/pr/sora-retrieve-video-sdk
feat: support openAI sdk retrieve videos
2025-10-10 19:18:27 +08:00
feitianbubu
ff9f9fbbc9 feat: support openAI sdk retrieve videos 2025-10-10 18:59:52 +08:00
CaIon
9b551d978d feat: add informational banner to proxy settings in SystemSetting.jsx 2025-10-10 16:44:41 +08:00
Seefs
76ab8a480a Merge pull request #1401 from feitianbubu/pr/add-qwen-channel-auto-disabled
feat: add qwen channel auto disabled
2025-10-10 16:41:43 +08:00
Calcium-Ion
f091f663c2 Merge pull request #1998 from seefs001/feature/pplx-channel
feat: pplx channel
2025-10-10 16:33:27 +08:00
Seefs
e8966c7374 feat: pplx channel 2025-10-10 16:12:15 +08:00
Calcium-Ion
5a7f498629 Merge pull request #1997 from feitianbubu/pr/add-sora-fetch-task
支持Sora做为上游渠道
2025-10-10 16:10:58 +08:00
feitianbubu
4c1f138c0a fix: sora fetch task route 2025-10-10 16:01:06 +08:00
Calcium-Ion
f4d7bde20b Merge pull request #1996 from seefs001/fix/remark-ignore
fix: channel remark ignore issue
2025-10-10 15:41:30 +08:00
Calcium-Ion
0c181395b4 Merge pull request #1992 from seefs001/pr-upstream-1981
feat(web): add settings & pages of privacy policy & user agreement
2025-10-10 15:41:06 +08:00
Seefs
6897a9ffd8 fix: channel remark ignore issue 2025-10-10 15:40:00 +08:00
Calcium-Ion
77130dfb87 Merge commit from fork
feat: enhance HTTP client wit redirect handling and SSRF protection
2025-10-10 15:30:37 +08:00
Calcium-Ion
614abc3441 Merge pull request #1995 from seefs001/fix/test-channel-1993
fix: test model #1993
2025-10-10 15:26:18 +08:00
feitianbubu
2479da4986 feat: add sora video fetch task 2025-10-10 15:25:29 +08:00
CaIon
7b732ec4b7 feat: enhance HTTP client with custom redirect handling and SSRF protection 2025-10-10 15:13:41 +08:00
Seefs
0fed791ad9 fix: test model #1993 2025-10-10 15:01:24 +08:00
Seefs
7de02991a1 Merge pull request #1994 from feitianbubu/pr/fix-video-model
fix: avoid get model consuming body
2025-10-10 14:28:16 +08:00
feitianbubu
3c57cfbf71 fix: avoid get model consuming body 2025-10-10 14:19:49 +08:00
Seefs
fe9b305232 fix: legal setting 2025-10-10 13:18:26 +08:00
キュビビイ
17dafa3b03 feat: add user agreement and privacy policy to login page 2025-10-09 22:21:56 +08:00
Seefs
5f5b9425df Merge pull request #1988 from feitianbubu/pr/add-sora
新增Sora视频渠道
2025-10-09 15:37:31 +08:00
feitianbubu
b880094296 feat: add sora video proxy video content 2025-10-09 15:00:02 +08:00
feitianbubu
9c37b63f2e feat: add sora video retrieve task 2025-10-09 15:00:02 +08:00
feitianbubu
9f4a2d64a3 feat: add sora video submit task 2025-10-09 15:00:02 +08:00
CaIon
e24f13a277 fix: update axios package version to 1.12.0 2025-10-09 14:21:49 +08:00
Calcium-Ion
d67c57eaa5 Merge pull request #1986 from QuantumNous/dependabot/npm_and_yarn/electron/electron-35.7.5
chore(deps-dev): bump electron from 28.3.3 to 35.7.5 in /electron
2025-10-09 14:18:42 +08:00
CaIon
60dc910a27 fix: update jwt package import to v5 across multiple files 2025-10-09 14:17:49 +08:00
dependabot[bot]
629a534798 chore(deps-dev): bump electron from 28.3.3 to 35.7.5 in /electron
Bumps [electron](https://github.com/electron/electron) from 28.3.3 to 35.7.5.
- [Release notes](https://github.com/electron/electron/releases)
- [Changelog](https://github.com/electron/electron/blob/main/docs/breaking-changes.md)
- [Commits](https://github.com/electron/electron/compare/v28.3.3...v35.7.5)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 35.7.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-09 06:07:32 +00:00
Seefs
15a7edf6d6 Merge pull request #1982 from RedwindA/feat/zhipu_cache
fix(openai): account cached tokens for zhipu_v4 usage
2025-10-09 12:27:11 +08:00
Seefs
cdd2eb517e Merge pull request #1984 from RedwindA/feat/claude-fetchModels
feat: add GetClaudeAuthHeader function and update FetchUpstreamModels to support Anthropic channel type
2025-10-09 12:22:47 +08:00
Seefs
1a398bbc40 Merge pull request #1983 from Sh1n3zZ/feat-imagen-related
feat: gemini imagen quality value
2025-10-09 12:22:02 +08:00
RedwindA
581c51f312 feat: add GetClaudeAuthHeader function and update FetchUpstreamModels to support Anthropic channel type 2025-10-09 02:43:19 +08:00
Sh1n3zZ
8f00af181b feat: gemini imagen quality value 2025-10-09 01:16:04 +08:00
CaIon
0c417e8ec6 feat: update tab label in index.jsx for clarity on pricing settings 2025-10-08 20:56:51 +08:00
RedwindA
f930cdbb51 fix(openai): account cached tokens for
zhipu_v4 usage
2025-10-08 16:52:49 +08:00
キュビビイ
4d0a9d9494 feat: componentize User Agreement and Privacy Policy display
Extracted the User Agreement and Privacy Policy presentation into a
reusable DocumentRenderer component (web/src/components/common/DocumentRenderer).
Unified rendering logic and i18n source for these documents, removed the
legacy contentDetector utility, and updated the related pages to use the
new component. Adjusted controller/backend (controller/misc.go) and locale
files to support the new rendering approach.

This improves reuse, maintainability, and future extensibility.
2025-10-08 11:12:49 +08:00
キュビビイ
6891057647 feat(web): add settings & pages of privacy policy & user agreement 2025-10-08 10:43:47 +08:00
Seefs
a610ef48e4 Merge pull request #1977 from QuantumNous/fix/bills
❤ fix(topup): prevent nil-pointer in Epay callback; reset page on search
2025-10-07 14:15:48 +08:00
Apple\Apple
ddf5c85b81 ❤ fix(topup): prevent nil-pointer in Epay callback; reset page on search
Add early return when Epay client is missing in controller/topup.go to avoid panic
Introduce handleKeywordChange in TopupHistoryModal.jsx to reset page to 1 when keyword updates
Wire input onChange to new handler; minor UX improvement to avoid empty results on pagination mismatch
2025-10-07 14:13:14 +08:00
Seefs
ec590d1075 Merge pull request #1976 from QuantumNous/feature/invoicing
 feat: Add topup billing history with admin manual completion
2025-10-07 12:08:33 +08:00
Apple\Apple
a8c9b24c7e 🔎 feat(topup): add order number search for billing history (admin and user)
Enable searching topup records by trade_no across both admin-wide and user-only views.

Frontend
- TopupHistoryModal.jsx:
  - Add search input with prefix icon (IconSearch) to filter by order number
  - Send `keyword` query param to backend; works with both endpoints:
    - Admin: GET /api/user/topup?p=1&page_size=10&keyword=...
    - User:  GET /api/user/topup/self?p=1&page_size=10&keyword=...
  - Keep endpoint auto-switching based on role (isAdmin)
  - Minor UI polish: outlined admin action button; keep Coins icon for amount

Backend
- model/topup.go:
  - Add SearchUserTopUps(userId, keyword, pageInfo)
  - Add SearchAllTopUps(keyword, pageInfo)
  - Both support pagination and `trade_no LIKE %keyword%` filtering (ordered by id desc)
- controller/topup.go:
  - GetUserTopUps / GetAllTopUps accept optional `keyword` and route to search functions when present

Routes
- No new endpoints; search is enabled via `keyword` on existing:
  - GET /api/user/topup
  - GET /api/user/topup/self

Affected files
- model/topup.go
- controller/topup.go
- web/src/components/topup/modals/TopupHistoryModal.jsx
2025-10-07 00:55:01 +08:00
Apple\Apple
2389dbafc5 feat(topup): Admin-wide topup listing and route reorganization
Allow administrators to view all platform topup orders and streamline admin-only routes.

Frontend
- TopupHistoryModal: dynamically switch endpoint by role
  - Admin → GET /api/user/topup (all orders)
  - Non-admin → GET /api/user/topup/self (own orders)
- Use shared utils `isAdmin()`; keep logic centralized and DRY
- Minor UI: set admin action button theme to outline for clarity

Backend
- model/topup.go: add GetAllTopUps(pageInfo) with pagination (ordered by id desc)
- controller/topup.go: add GetAllTopUps handler returning PageInfo response
- router/api-router.go:
  - Add admin route GET /api/user/topup (AdminAuth)
  - Move POST /api/user/topup/complete to adminRoute (keeps path stable, consolidates admin endpoints)

Security/Behavior
- Admin-only endpoints now reside under the admin route group with AdminAuth
- No behavior change for regular users; no schema changes

Affected files
- model/topup.go
- controller/topup.go
- router/api-router.go
- web/src/components/topup/modals/TopupHistoryModal.jsx
2025-10-07 00:46:47 +08:00
Apple\Apple
6ef95c97cc feat: Add topup billing history with admin manual completion
Implement comprehensive topup billing system with user history viewing and admin management capabilities.

## Features Added

### Frontend
- Add topup history modal with paginated billing records
- Display order details: trade number, payment method, amount, money, status, create time
- Implement empty state with proper illustrations
- Add payment method column with localized display (Stripe, Alipay, WeChat)
- Add admin manual completion feature for pending orders
- Add Coins icon for recharge amount display
- Integrate "Bills" button in RechargeCard header
- Optimize code quality by using shared utility functions (isAdmin)
- Extract constants for status and payment method mappings
- Use React.useMemo for performance optimization

### Backend
- Create GET `/api/user/topup/self` endpoint for user topup history with pagination
- Create POST `/api/user/topup/complete` endpoint for admin manual order completion
- Add `payment_method` field to TopUp model for tracking payment types
- Implement `GetUserTopUps` method with proper pagination and ordering
- Implement `ManualCompleteTopUp` with transaction safety and row-level locking
- Add application-level mutex locks to prevent concurrent order processing
- Record payment method in Epay and Stripe payment flows
- Ensure idempotency and data consistency with proper error handling

### Internationalization
- Add i18n keys for Chinese (zh), English (en), and French (fr)
- Support for billing-related UI text and status messages

## Technical Improvements
- Use database transactions with FOR UPDATE row-level locking
- Implement sync.Map-based mutex for order-level concurrency control
- Proper error handling and user-friendly toast notifications
- Follow existing codebase patterns for empty states and modals
- Maintain code quality with extracted render functions and constants

## Files Changed
- Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go
- Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx
- i18n: web/src/i18n/locales/{zh,en,fr}.json
2025-10-07 00:22:45 +08:00
Seefs
2397ec8075 Merge pull request #1974 from RedwindA/fix/Visibility
fix: improve text visibility in warning box for dark mode in SettingsLog
2025-10-06 14:51:07 +08:00
CaIon
c24608730b CI 2025-10-06 14:33:48 +08:00
RedwindA
ca9ee54fba fix: improve text visibility in warning box for dark mode in SettingsLog 2025-10-05 23:39:20 +08:00
CaIon
bb0ed4dddf feat: implement user preferences management in SettingsLog component for improved customization 2025-10-05 23:11:45 +08:00
CaIon
407da544fe feat: enhance SettingsLog component with confirmation modal for log deletion and improve user feedback 2025-10-05 22:51:28 +08:00
CaIon
98261ec9fa chore: update README files 2025-10-05 19:40:31 +08:00
CaIon
74f93d41f3 feat: update Gemini API response handling to include block reason and improve error reporting 2025-10-05 19:33:47 +08:00
CaIon
021892b17d feat: set data directory path in main process and update preload.js to use it 2025-10-05 18:38:25 +08:00
CaIon
9f44116260 fix: update versioning logic in electron-build.yml to correctly handle prerelease formats and modify product name in package.json 2025-10-05 18:20:43 +08:00
CaIon
8a56795bd8 chore: update electron-build.yml to add write permissions and enable file overwriting in artifact uploads 2025-10-05 17:50:35 +08:00
CaIon
1154077eea feat: enhance versioning logic in electron-build.yml for semver compliance 2025-10-05 17:31:01 +08:00
Calcium-Ion
42861bc5fb Merge pull request #1964 from bubblepipe/electron
feat: Add Electron wrapper for desktop app
2025-10-05 17:20:23 +08:00
CaIon
7074ea2ed6 chore: upgrade action-gh-release to v2 in build workflows 2025-10-05 17:19:52 +08:00
CaIon
414be64d33 fix: correct Windows path handling in preload.js and update .gitignore for consistency 2025-10-05 17:15:10 +08:00
CaIon
c1137027e6 chore: update build workflows for Electron and Go, including version tagging and dependency management 2025-10-05 17:11:30 +08:00
CaIon
ff77ba1157 feat: enhance Electron environment detection and improve database warnings 2025-10-05 16:45:29 +08:00
CaIon
3da7cebec6 feat: 防呆优化 2025-10-05 15:44:00 +08:00
Seefs
7dc5f8c92d Merge pull request #1967 from MomentDerek/main
fix: Gemini missing func name for multi-streaming tool calls (except the first)
2025-10-04 14:53:51 +08:00
Moment
7763f11da7 fix: Gemini missing func name for multi-streaming tool calls (except the first). 2025-10-04 13:21:07 +08:00
CaIon
7437b671ef 💱 feat: implement currency configuration helper and update currency display logic in RechargeCard and render functions 2025-10-03 21:49:24 +08:00
CaIon
55d19df029 chore: remove outdated instructions from pull request template 2025-10-03 21:40:07 +08:00
CaIon
731e9f4ca9 💱 feat(RechargeCard): enhance currency display logic for top-up amounts based on user settings 2025-10-03 21:39:28 +08:00
Calcium-Ion
cc6fcebda1 Merge pull request #1957 from seefs001/pr/custom-currency-1923
💱 feat(settings): introduce site-wide quota display type
2025-10-03 21:17:16 +08:00
CaIon
72a12e3747 chore: update README files 2025-10-03 20:28:26 +08:00
CaIon
0adfcf9d27 chore: update README files 2025-10-03 20:27:34 +08:00
CaIon
51d71a6e1a feat: add Spanish feature request template to GitHub issue tracker for improved feature proposal submissions 2025-10-03 14:45:39 +08:00
bubblepipe42
8026e5142b fix deps 2025-10-03 14:30:48 +08:00
bubblepipe42
9e33c83351 merge 2025-10-03 14:29:45 +08:00
bubblepipe42
d2492d2af9 fix deps 2025-10-03 14:28:29 +08:00
CaIon
c9abe1d769 feat: add English feature request template to GitHub issue tracker for enhanced feature proposal submissions 2025-10-03 14:01:16 +08:00
CaIon
a8bfa7ad29 feat: add English bug report template to GitHub issue tracker for improved issue reporting #1960 2025-10-03 13:59:27 +08:00
bubblepipe42
93e30703d4 action 2025-10-03 13:55:19 +08:00
bubblepipe42
b39885be1e action 2025-10-03 13:23:56 +08:00
Seefs
f24feed775 Merge pull request #1963 from feitianbubu/pr/refactor-channel-test
refactor: simplify unsupported test channel types
2025-10-03 12:46:38 +08:00
CaIon
6b75bc0016 refactor(openai_image): replace json.Marshal with common.Marshal for improved serialization #1961 2025-10-03 12:44:33 +08:00
feitianbubu
937d931442 refactor: simplify unsupported test channel types 2025-10-03 12:41:39 +08:00
Seefs
5c6e6032ef Merge pull request #1959 from RedwindA/feat/silicon-fim
feat: Allow FIM chat requests without messages
2025-10-03 12:37:38 +08:00
CaIon
d5e01a3eab feat(gemini): add imageConfig field to GeminiChatRequest for flexible image configuration 2025-10-03 12:26:17 +08:00
RedwindA
69e1542fc9 feat: Allow FIM chat requests without messages 2025-10-03 02:27:02 +08:00
Seefs
3199e2e8cd Merge branch 'main-upstream' into pr/custom-currency-1923
# Conflicts:
#	web/src/components/settings/personal/cards/AccountManagement.jsx
#	web/src/components/table/channels/modals/EditChannelModal.jsx
#	web/src/hooks/channels/useChannelsData.jsx
#	web/src/hooks/common/useSidebar.js
#	web/src/i18n/locales/fr.json
#	web/src/pages/Setting/Operation/SettingsGeneral.jsx
2025-10-02 20:30:48 +08:00
CaIon
66d0764fc1 docs: update README files to include Japanese language support 2025-10-02 19:55:37 +08:00
CaIon
01bcbf09c6 feat(api): add header override processing with variable support 2025-10-02 19:29:57 +08:00
CaIon
0682a15971 Merge remote-tracking branch 'origin/main' 2025-10-02 19:01:13 +08:00
Calcium-Ion
19bbb7d7c7 Merge pull request #1890 from QuantumNous/pr/console-footer
 feat(layout): refine footer visibility logic to target CardPro component pages
2025-10-02 19:00:57 +08:00
CaIon
ace855ed36 refactor(footer): update footer links and localization text
- Removed the 'chatnio' link from the footer.
- Added new links for 'CoAI' and 'GPT-Load' in the footer.
- Updated the localization key for '基于New API的项目' to '友情链接' for better clarity.
- Adjusted the design of the footer to improve layout and visibility of the developer credit.
2025-10-02 19:00:07 +08:00
CaIon
36ed41ad7a Merge remote-tracking branch 'origin/pr/console-footer' into pr/console-footer
# Conflicts:
#	web/src/components/table/channels/modals/EditChannelModal.jsx
#	web/src/hooks/common/useSidebar.js
2025-10-02 18:46:16 +08:00
t0ng7u
df19a8de5d feat(layout): refine footer visibility logic to target CardPro component pages
- Replace blanket console route footer hiding with specific page targeting
- Only hide footer on pages that use CardPro component:
  * /console/channel (channels management)
  * /console/log (usage logs)
  * /console/redemption (redemption codes)
  * /console/user (user management)
  * /console/token (token management)
  * /console/midjourney (midjourney logs)
  * /console/task (task logs)
  * /console/models (model management)
  * /pricing (pricing page)
- Footer now displays on other console pages (dashboard, settings, topup, etc.)
- Improves UI consistency by showing footer where CardPro's internal pagination isn't used

This change ensures footer is only hidden when CardPro component provides its own
pagination/footer functionality, while preserving footer visibility on other pages
that benefit from the global footer navigation.
2025-10-02 18:45:37 +08:00
CaIon
0074085b13 Merge remote-tracking branch 'origin/main' 2025-10-02 18:43:32 +08:00
Calcium-Ion
f473d20a09 Merge pull request #1932 from seefs001/chore/upgrade-deps
chore: go version & sonic dep
2025-10-02 18:37:56 +08:00
Calcium-Ion
9061411ec7 Merge pull request #1940 from comeback01/french-translation-final
feat(i18n): add and update French translations
2025-10-02 18:37:34 +08:00
comeback01
6ee01d75a6 Merge branch 'main' into french-translation-final 2025-10-02 10:57:34 +02:00
CaIon
b0b275b236 chore(docker): add comment for compatibility with older Docker versions 2025-10-02 16:20:15 +08:00
CaIon
81a66be721 chore(docker): switch from MySQL to PostgreSQL in docker-compose configuration 2025-10-02 16:14:15 +08:00
CaIon
01469aa01c refactor(adaptor): extract common header operations into a separate function 2025-10-02 15:28:09 +08:00
Calcium-Ion
0769184b9b Merge pull request #1956 from QuantumNous/alpha
alpha -> main
2025-10-02 15:26:59 +08:00
bubblepipe42
15b21c075f windows tray icon 2025-10-02 14:55:06 +08:00
Seefs
4137120d69 Merge pull request #1779 from feitianbubu/pr/fix-video-get-task
fix: get video task err when Content-Type=json
2025-10-02 14:43:18 +08:00
Seefs
3d1433dd70 Merge branch 'alpha' into pr/fix-video-get-task 2025-10-02 14:43:11 +08:00
Seefs
acbfc9d3b3 Merge pull request #1951 from feitianbubu/pr/add-doubao-video
增加豆包视频渠道
2025-10-02 14:41:41 +08:00
Seefs
4cdad47695 Merge pull request #1955 from seefs001/fix/megge-conflict
fix: merge conflict
2025-10-02 14:30:25 +08:00
Seefs
6a1de0ebdc fix: merge conflict 2025-10-02 14:28:58 +08:00
Seefs
92a4e88ceb Merge pull request #1844 from JoeyLearnsToCode/feat-channel-block-edit
feat: Add navigation buttons for channel edit form sections
2025-10-02 14:16:11 +08:00
Seefs
e9043590a9 Merge branch 'alpha' into feat-channel-block-edit 2025-10-02 14:16:01 +08:00
Seefs
9e6828653b Merge pull request #1947 from HynoR/feat/newedit
feat: Add visual editing mode for chat configurations
2025-10-02 14:11:49 +08:00
Seefs
dae661bb53 Merge pull request #1948 from RedwindA/feat/gotify
feat: Add Gotify Notification Channel for Quota Alerts
2025-10-02 14:11:20 +08:00
Seefs
649a5205c9 Merge pull request #1950 from seefs001/fix/missing-field
fix: missing field & field control
2025-10-02 14:10:11 +08:00
Seefs
26a563da54 fix: Return the original payload and nil error on Unmarshal or Marshal failures in RemoveDisabledFields 2025-10-02 13:57:49 +08:00
Seefs
c1492be131 Merge pull request #1954 from QuantumNous/main
main -> alpha
2025-10-02 13:50:18 +08:00
feitianbubu
7ca65a5e8e feat: add doubao video add log detail 2025-10-02 04:00:50 +08:00
feitianbubu
b244a06ca1 feat: add doubao video use quota by total token 2025-10-02 04:00:46 +08:00
feitianbubu
c320410c84 feat: add doubao video generate 2025-10-02 04:00:43 +08:00
Seefs
2938246f2e Merge pull request #1949 from RedwindA/fix/oai-responses-webSearch-panic
fix(openai): add nil checks for web_search streaming to prevent panic
2025-10-02 00:36:25 +08:00
Seefs
0e9ad4a15f fix: missing field & field control 2025-10-02 00:14:35 +08:00
RedwindA
2200bb9166 fix(openai): add nil checks for web_search streaming to prevent panic 2025-10-01 22:19:22 +08:00
RedwindA
d6db10b4bc feat: 添加 Bark 和 Gotify 通知的国际化支持 2025-10-01 19:36:19 +08:00
RedwindA
85ff8b1422 feat: add Gotify notification option for quota alerts 2025-10-01 19:15:00 +08:00
HynoR
1428338546 feat: Enhance SettingsChats edit interface 2025-10-01 18:40:02 +08:00
HynoR
ec76b0f5e2 feat: add visual editing mode for chat configurations 2025-10-01 18:22:32 +08:00
Seefs
96b172e93b Merge pull request #1945 from QuantumNous/gemini-robotics-er
feat: 支持 gemini-robotics-er-1.5-preview
2025-10-01 17:41:41 +08:00
creamlike1024
70263e96ab feat: 支持 gemini-robotics-er-1.5-preview 2025-10-01 17:33:54 +08:00
Seefs
15db5c0062 Merge pull request #1943 from RedwindA/fix/jina-embedding
fix(jina): remove encoding_format for jina embedding
2025-10-01 16:58:56 +08:00
RedwindA
f5a774f22c fix(jina): remove encoding_format for jina embedding 2025-10-01 02:06:30 +08:00
comeback01
0735b0c604 feat(i18n): add and update French translations 2025-09-30 17:07:45 +02:00
CaIon
0b91e45197 fix(adaptor): update relay mode handling to support relay format 2025-09-30 16:53:57 +08:00
CaIon
6bc3e62fd5 feat: add endpoint type selection to channel testing functionality 2025-09-30 16:52:14 +08:00
bubblepipe
922ecef31e fix wn 2025-09-30 16:41:41 +08:00
Calcium-Ion
3ba2aaee32 Merge pull request #1936 from seefs001/fix/passkey
fix: passkey 文案
2025-09-30 16:19:01 +08:00
Seefs
0a6f39e60b fix: passkey 文案 2025-09-30 16:15:33 +08:00
bubblepipe
573b5c3e3b fix build on windows 2025-09-30 15:55:31 +08:00
Calcium-Ion
1bd791d603 Merge pull request #1934 from seefs001/fix/passkey
fix: passkey rpid detect
2025-09-30 15:54:49 +08:00
Seefs
fcc6172b43 fix: passkey rpid detect 2025-09-30 15:53:19 +08:00
Seefs
c4e0fc1837 chore: go version & sonic dep 2025-09-30 14:38:20 +08:00
Seefs
7533ffc3ee Merge pull request #1931 from seefs001/feature/passkey
fix: passkey security
2025-09-30 13:28:18 +08:00
Seefs
e71407ee62 fix: passkey security 2025-09-30 13:18:18 +08:00
Seefs
d026edc1b3 Merge pull request #1930 from seefs001/feature/passkey
fix: passkey model type
2025-09-30 12:52:32 +08:00
Seefs
ab166649bc fix: blob type 2025-09-30 12:40:05 +08:00
Seefs
9f20e49100 Merge pull request #1912 from seefs001/feature/passkey
feat: passkey
2025-09-30 12:28:09 +08:00
Seefs
013a575541 fix: personal setting 2025-09-30 12:26:24 +08:00
Seefs
4c13666f26 Merge branch 'main-upstream' into feature/passkey
# Conflicts:
#	web/src/components/settings/PersonalSetting.jsx
#	web/src/i18n/locales/en.json
#	web/src/i18n/locales/zh.json
2025-09-30 12:15:20 +08:00
Seefs
e8425addf0 feat: 通用二步验证 2025-09-30 12:12:50 +08:00
Seefs
aab82f22fa Merge pull request #1817 from wzxjohn/hotfix/relay_vertex_claude
fix(relay): wrong URL for claude model in GCP Vertex AI
2025-09-30 11:27:15 +08:00
CaIon
8e10af82b1 fix(main): conditionally log missing .env file message based on debug mode 2025-09-30 11:22:00 +08:00
Calcium-Ion
595e3fed91 Merge pull request #1920 from QuantumNous/alpha
Alpha -> main
2025-09-30 11:16:45 +08:00
Seefs
933ab4340b Merge pull request #1925 from seefs001/feature/claude-context-editing
feat: claude context editing
2025-09-30 09:48:32 +08:00
Seefs
3b306bb5d3 Merge pull request #1926 from seefs001/fix/claude-beta
fix: claude beta=true
2025-09-30 09:48:18 +08:00
Seefs
8118424039 fix: claude beta=true 2025-09-30 09:46:46 +08:00
Seefs
7d7ffc05ad Merge pull request #1921 from RedwindA/refactor/improve-sidebar-perf
fix: Optimize sidebar refresh to avoid redundant loading states
2025-09-30 09:38:45 +08:00
Seefs
31544405f4 Merge pull request #1924 from prnake/claude-4-5
feat: support claude-sonnet-4-5-20250929
2025-09-30 09:26:34 +08:00
Seefs
30cb3b8bc2 feat: claude context editing 2025-09-30 09:22:40 +08:00
papersnake
d7db30a23e feat: support claude-sonnet-4-5-20250929 2025-09-30 09:14:12 +08:00
t0ng7u
39a868faea 💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM)
Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe
configuration `general_setting.quota_display_type`, and wire it through the
backend and frontend.

Backend
- Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected
  registration via `config.GlobalConfig.Register("general_setting", ...)`.
  Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`.
- Expose `quota_display_type` in `/api/status` and keep legacy
  `display_in_currency` for backward compatibility.
- Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When
  CNY is selected, convert using `operation_setting.USDExchangeRate`.
- Controllers:
  - `billing`: compute subscription/usage amounts based on the selected type
    (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens).
  - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as
    token-count for TOKENS; adjust min topup and pay money accordingly.
  - `misc`: include `quota_display_type` in status payload.
- Compatibility: in `model/option.UpdateOption`, map updates to
  `DisplayInCurrencyEnabled` → `general_setting.quota_display_type`
  (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`.

Frontend
- Settings: replace the “display in currency” switch with a Select
  (`general_setting.quota_display_type`) offering USD / CNY / Tokens.
  Provide fallback mapping from legacy `DisplayInCurrencyEnabled`.
- Persist `quota_display_type` to localStorage (keep `display_in_currency`
  for legacy components).
- Rendering helpers: base all quota/price rendering on `quota_display_type`;
  use `usd_exchange_rate` for CNY symbol/values.
- Pricing page: default view currency follows site display type (USD/CNY),
  while TOKENS mode still allows per-view currency toggling when needed.

Notes
- No database migrations required.
- Legacy clients remain functional via compatibility fields.
2025-09-29 23:23:31 +08:00
RedwindA
fa45cb5279 fix: Optimize sidebar refresh to avoid redundant loading states 2025-09-29 22:16:25 +08:00
Seefs
25a3896e5c Merge pull request #1919 from seefs001/fix/submodel
fix : fix submodel adapter
2025-09-29 21:56:53 +08:00
Seefs
76180b1df4 fix: submodel adapter 2025-09-29 21:55:34 +08:00
Seefs
84cdd24116 feat: passkey wip 2025-09-29 21:54:16 +08:00
Seefs
83b2b071fd Merge pull request #1915 from danding5/main
add submodel.ai
2025-09-29 21:54:00 +08:00
Seefs
4bb4b64184 Merge pull request #1916 from RedwindA/fix/3rd-binding-state
fix: sync third-party binding state in personal settings
2025-09-29 20:24:58 +08:00
Seefs
6c0a79dab8 Merge pull request #1918 from tbphp/fix-tg-302
fix: Redirect address after successful tg binding
2025-09-29 20:24:28 +08:00
tbphp
d249532473 fix: Redirect address after successful tg binding 2025-09-29 20:19:34 +08:00
dd
fc2d9922f8 Update constant/api_type.go
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-29 19:37:04 +08:00
RedwindA
1b627ddb5e fix: sync third-party binding state in personal settings 2025-09-29 19:23:42 +08:00
dd
8c5b6654cb Merge branch 'QuantumNous:main' into main 2025-09-29 19:15:43 +08:00
Seefs
9f989fc7ef Merge pull request #1913 from RedwindA/fix/deepseek-ratio
解锁deepseek补全倍率;允许deepseek渠道获取模型
2025-09-29 18:41:33 +08:00
RedwindA
ca0eaa7697 解锁deepseek补全倍率;允许deepseek渠道获取模型 2025-09-29 18:32:44 +08:00
Seefs
dcf4336c75 feat: passkey 2025-09-29 17:45:09 +08:00
CaIon
c7a52370fc fix(models): increase varchar length for TaskID and Username fields #1905 2025-09-29 16:45:46 +08:00
CaIon
c3660938e0 Merge remote-tracking branch 'origin/main' 2025-09-29 16:20:59 +08:00
Calcium-Ion
6983d9f91f Merge pull request #1889 from comeback01/traduction
feat(i18n): Add French language support.
2025-09-29 16:20:49 +08:00
CaIon
c2bbfd7fe7 chore: remove bun_output.log and package-lock.json files 2025-09-29 16:19:54 +08:00
dd
d0a850468d Update render.jsx 2025-09-29 15:14:02 +08:00
comeback01
e71df436e2 feat(i18n): add French translation for stripe promo codes 2025-09-29 09:12:37 +02:00
dd
5840de1df8 Update relay_adaptor.go 2025-09-29 15:11:17 +08:00
comeback01
9c2082f41c Chore: remove temporary verification script 2025-09-29 09:03:54 +02:00
comeback01
e647878031 Merge branch 'main' into traduction
# Conflicts:
#	web/src/i18n/locales/zh.json
2025-09-29 09:03:32 +02:00
dd
4c2979bb67 Merge branch 'QuantumNous:main' into main 2025-09-29 14:13:50 +08:00
CaIon
09e5e5d68c fix(http_client): improve error message for unsupported proxy schemes 2025-09-29 14:02:15 +08:00
bubblepipe42
7c7f9abd04 icon 2025-09-29 13:41:17 +08:00
bubblepipe42
050e0221c7 README 2025-09-29 13:33:41 +08:00
bubblepipe42
a57a36a739 tray 2025-09-29 13:25:22 +08:00
bubblepipe42
0046282fb8 stash 2025-09-29 13:01:07 +08:00
Seefs
a91f3e7556 Merge pull request #1910 from seefs001/fix/volcengine_default_baseurl
alpha -> main
2025-09-29 12:30:24 +08:00
Seefs
bf9a5f5b52 Merge branch 'main-upstream' into fix/volcengine_default_baseurl
# Conflicts:
#	web/src/components/settings/personal/cards/AccountManagement.jsx
2025-09-29 12:17:44 +08:00
Seefs
7d49ce6da7 fix: set volcengine default url 2025-09-29 12:15:38 +08:00
Seefs
a2b5efb6bd Merge pull request #1904 from RedwindA/fix/wechat-display
Fix third-party binding states and unify Telegram button styling in Account Management
2025-09-29 12:14:23 +08:00
Seefs
d916456801 Merge pull request #1894 from RedwindA/refactor/enhance-channel-proxy
Refactor: Cache Proxy HTTP Clients with Reset on Channel Updates
2025-09-29 12:12:17 +08:00
Seefs
9a1ef8b957 Merge branch 'main-upstream' into fix/volcengine_default_baseurl
# Conflicts:
#	main.go
2025-09-29 12:08:52 +08:00
bubblepipe42
723eefe9d8 electron 2025-09-29 11:08:52 +08:00
comeback01
f71bf9e82f Merge remote-tracking branch 'upstream/main' into traduction 2025-09-28 12:23:17 +02:00
RedwindA
e2798fa62f fix(settings): ensure turnstile settings are reset when disabled 2025-09-28 17:38:56 +08:00
RedwindA
b08f1889e8 fix(settings): correct third-party binding states and unify Telegram
button styling

  - Show “Not enabled” for WeChat when status.wechat_login is false.
  - Refresh /api/status on PersonalSetting mount and persist via
  setStatusData to avoid stale flags, enabling binding after admin turns
  on OAuth.
  - Unify Telegram button styling with other providers; open a modal to
  render TelegramLoginButton for binding; align disabled “Not enabled” and
  “Bound” states.
  - Introduce isBound helper and reuse across providers (email/GitHub/OIDC/
  LinuxDO/WeChat) to simplify checks and prevent falsy-ID issues.
2025-09-28 17:31:38 +08:00
Calcium-Ion
045ba23566 Merge pull request #1897 from QuantumNous/openrouter-enterprise
feat: 添加 openrouter-enterprise 支持
2025-09-28 15:31:01 +08:00
CaIon
7fe969c2ce fix: streamline error handling in OpenRouter response processing 2025-09-28 15:29:01 +08:00
CaIon
b91eb8a5ac Merge remote-tracking branch 'origin/openrouter-enterprise' into openrouter-enterprise
# Conflicts:
#	relay/channel/openai/relay-openai.go
2025-09-28 15:23:40 +08:00
CaIon
6e6a96d19f feat: enhance OpenRouter enterprise support with new settings and response handling 2025-09-28 15:23:27 +08:00
Calcium-Ion
f6be18eca4 Merge pull request #1819 from RixAPI/main
优化渠道测试
2025-09-28 14:31:29 +08:00
Calcium-Ion
bdefed7b0a Merge pull request #1811 from somnifex/main
refactor: 重构ollama渠道
2025-09-28 14:30:45 +08:00
JoeyLearnsToCode
3ed0ae83f1 Merge remote-tracking branch 'remotes/up-origin/main' into feat-channel-block-edit 2025-09-28 09:56:35 +08:00
creamlike1024
ee7ce5a476 feat: 添加 openrouter-enterprise 支持 2025-09-28 09:35:07 +08:00
Calcium-Ion
6659a8a569 Merge pull request #1836 from joesonshaw/main
fix(relay-xunfei): 修复讯飞渠道无法使用问题 #1740
2025-09-28 01:17:22 +08:00
RedwindA
466d19c33d fix: add missing timeout 2025-09-27 22:29:27 +08:00
RedwindA
486c828df0 feat: 在添加和更新渠道时重置代理客户端缓存 2025-09-27 22:18:46 +08:00
RedwindA
c68fd36ee1 feat: 添加代理客户端缓存和重置功能,优化 HTTP 客户端管理 2025-09-27 22:18:39 +08:00
Seefs
74122e4175 Merge pull request #1886 from ShibaInu64/feature/volcengine-base-url
feat: volcengine支持自定义域名
2025-09-27 20:06:04 +08:00
huanghejian
2e4405e2bd Merge remote-tracking branch 'origin/main' into feature/volcengine-base-url 2025-09-27 19:53:42 +08:00
t0ng7u
f354e5de23 feat(layout): refine footer visibility logic to target CardPro component pages
- Replace blanket console route footer hiding with specific page targeting
- Only hide footer on pages that use CardPro component:
  * /console/channel (channels management)
  * /console/log (usage logs)
  * /console/redemption (redemption codes)
  * /console/user (user management)
  * /console/token (token management)
  * /console/midjourney (midjourney logs)
  * /console/task (task logs)
  * /console/models (model management)
  * /pricing (pricing page)
- Footer now displays on other console pages (dashboard, settings, topup, etc.)
- Improves UI consistency by showing footer where CardPro's internal pagination isn't used

This change ensures footer is only hidden when CardPro component provides its own
pagination/footer functionality, while preserving footer visibility on other pages
that benefit from the global footer navigation.
2025-09-27 18:47:53 +08:00
comeback01
79a252fc57 fix(test): Make language verification script more robust
This addresses feedback from CodeRabbitAI by using a regular expression for the language button's aria-label. This ensures the test can run regardless of the browser's default language.
2025-09-27 11:35:03 +02:00
google-labs-jules[bot]
72177c2c50 fix(i18n): nest common.changeLanguage under common object
- Restructured the `common.changeLanguage` key to be nested under a `common` object in `en.json`, `fr.json`, and `zh.json`.
- This change improves the organization of the translation files and aligns with best practices for i18next.
2025-09-27 11:18:54 +02:00
google-labs-jules[bot]
3e941fd4fa feat(i18n): complete French locale and add common.changeLanguage
- Added `common.changeLanguage` key to `en.json`, `fr.json`, and `zh.json`.
- Updated `LanguageSelector.jsx` to use the new shared key.
- Completed `fr.json` with all keys from `en.json` and `zh.json`.
- Added translations for `closeSidebar`, `pricing`, and `language`.
2025-09-27 11:18:54 +02:00
google-labs-jules[bot]
25dfc0af22 Ajout du lien vers la traduction française dans le fichier README.en.md. 2025-09-27 11:18:54 +02:00
google-labs-jules[bot]
2a0ecf3a1f Ajout du lien vers la traduction française dans le fichier README.md principal. 2025-09-27 11:18:54 +02:00
google-labs-jules[bot]
6736762713 Ajout de la traduction française du fichier README.
- Création du fichier `README.fr.md` en se basant sur `README.en.md`.
2025-09-27 11:18:54 +02:00
google-labs-jules[bot]
266f5784d7 Ajout de la traduction française à l'interface utilisateur.
- Création du fichier de traduction `fr.json` en se basant sur `en.json`.
- Mise à jour de la configuration i18n pour inclure la langue française.
- Modification du sélecteur de langue pour afficher l'option "Français" avec le drapeau correspondant.
2025-09-27 11:18:54 +02:00
huanghejian
923308a899 fix: 默认选择国内地址,不设置base url弹窗提示 2025-09-27 17:03:34 +08:00
huanghejian
e4efa34e6a pref: 防呆设计需要,不允许自定义,API地址优化为下拉选择 2025-09-27 16:40:18 +08:00
CaIon
143a2def24 feat: add startup logging with network IPs and container detection 2025-09-27 16:30:24 +08:00
Seefs
ffc077490c Merge pull request #1862 from HynoR/fix/dup3
feat: add duplicate key removal function when edit or add new channel
2025-09-27 16:21:14 +08:00
CaIon
476cf10495 feat: add startup logging with network IPs and container detection 2025-09-27 16:19:58 +08:00
Seefs
b294ff5e96 Merge pull request #1554 from feitianbubu/pr/fix-video-preview
feat: if video cannot play open in a new tab
2025-09-27 16:19:25 +08:00
Seefs
096141bfef Merge pull request #1888 from seefs001/feature/gemini-urlcontext
feat: gemini urlContext
2025-09-27 16:18:14 +08:00
Seefs
9e8b9995a6 feat: gemini urlContext 2025-09-27 16:16:34 +08:00
Seefs
a498da7ab2 Merge pull request #1887 from seefs001/fix/stripe
feat: allow stripe promotion code
2025-09-27 15:46:35 +08:00
Seefs
ad72500941 feat: allow stripe promotion code 2025-09-27 15:43:12 +08:00
Seefs
79859a3fc6 Merge pull request #1070 from wzxjohn/feature/umami
feat: support UMAMI analytics
2025-09-27 15:30:54 +08:00
Seefs
5197d874d7 Merge pull request #1853 from ZKunZhang/fix/playground-copy-newlines
fix: 修复多行代码复制换行丢失问题 & 优化API参数处理#1828
2025-09-27 15:25:44 +08:00
CaIon
e9e9708d1e feat: update release configuration to use new-api binaries for consistency 2025-09-27 15:24:40 +08:00
huanghejian
e0c6900195 pref: 优化代码 2025-09-27 15:19:54 +08:00
Calcium-Ion
bf99ead4a4 Merge pull request #1885 from RedwindA/fix/hide-unavailable-fetch-model-button
feat: `获取模型列表`按钮白名单
2025-09-27 15:12:01 +08:00
Calcium-Ion
474db61e56 Merge pull request #1884 from seefs001/fix/gemini
fix: add missing fields to Gemini request
2025-09-27 15:10:49 +08:00
CaIon
406be515db feat: rename output binaries to new-api for consistency across platforms 2025-09-27 15:04:06 +08:00
CaIon
7794788b1e Merge remote-tracking branch 'origin/main' 2025-09-27 13:56:44 +08:00
CaIon
2f74cc077b feat: 多密钥管理新增针对单个密钥的删除操作 2025-09-27 13:56:07 +08:00
huanghejian
25a8473e85 feat: volcengine支持自定义域名 2025-09-27 09:35:03 +08:00
RedwindA
c25f487c8f feat: 添加对 Zhipu v4 渠道获取模型列表的支持 2025-09-27 01:19:43 +08:00
RedwindA
4f05c8eafb feat: 仅为适当的渠道渲染获取模型列表按钮 2025-09-27 01:19:09 +08:00
Seefs
f4d95bf1c4 fix: jsonRaw 2025-09-27 00:33:05 +08:00
Seefs
391d4514c0 fix: jsonRaw 2025-09-27 00:24:29 +08:00
Seefs
c89c8a7396 fix: add missing fields to Gemini request 2025-09-27 00:15:28 +08:00
Seefs
d2defa1253 Merge pull request #1882 from ShibaInu64/feature/nova-model
feat: 新增支持目前已发布的amazon nova model
2025-09-26 17:25:40 +08:00
huanghejian
127029d62d feat: amazon nova model 2025-09-26 15:55:00 +08:00
huanghejian
6c5181977d feat: amazon nova model 2025-09-26 15:32:59 +08:00
IcedTangerine
6992fd2b66 Merge pull request #1809 from QuentinHsu/feature/date-shortcut
feat: add date range preset constants and use them in the log filter
2025-09-23 22:24:05 +08:00
IcedTangerine
92895ebe5a Merge branch 'main' into feature/date-shortcut 2025-09-23 22:21:25 +08:00
IcedTangerine
c0fb3bf95f Merge pull request #1810 from QuentinHsu/feature/alias-path
feat: add jsconfig.json and configure path aliases
2025-09-23 22:13:20 +08:00
Seefs
abe31f216f Merge pull request #1830 from MyPrototypeWhat/fix/UserArea-Dropdown
fix(UserArea): Enhance UserArea dropdown positioning with useRef
2025-09-22 12:59:54 +08:00
Seefs
44bc65691e Merge pull request #1777 from heimoshuiyu/feat/token-display-thoundsand-seperator
feat: add thousand separators to token display in dashboard
2025-09-22 12:56:49 +08:00
Seefs
b69245212a Merge pull request #1769 from QAbot-zh/fix/account-status
fix:Account Management Status
2025-09-22 12:48:51 +08:00
HynoR
2a54e989b4 feat: add duplicate key removal function when edit or add new channel 2025-09-21 17:26:56 +09:00
IcedTangerine
7c27558de9 Merge pull request #1841 from x-Ai/fix/sidebar-permissions
fix: 个人设置中修改边栏设置不立即生效
2025-09-21 15:08:18 +08:00
Seefs
51ef19a3fb Merge pull request #1850 from seefs001/fix/claude-system-prompt-overwrite
fix: claude & gemini endpoint system prompt overwrite
2025-09-20 14:00:38 +08:00
Seefs
8e7301b79a fix: gemini system prompt overwrite 2025-09-20 13:38:44 +08:00
CaIon
ec98a21933 feat: change ParallelToolCalls and Store fields to json.RawMessage type 2025-09-20 13:28:33 +08:00
CaIon
1dd59f5d08 feat: add PromptCacheKey field to openai_request struct 2025-09-20 13:27:32 +08:00
Zhaokun Zhang
2ffdf738bd fix: address copy functionality and code logic issues for #1828
- utils.jsx: Replace input with textarea in copy function to preserve line breaks in multi-line content, preventing formatting loss mentioned in #1828
- api.js: Fix duplicate 'group' property in buildApiPayload to resolve syntax issues
- MarkdownRenderer.jsx: Refactor code text extraction using textContent for accurate copying

Closes #1828

Signed-off-by: Zhaokun Zhang <zhaokunzhang@Zhaokuns-Air.lan>
2025-09-20 11:09:28 +08:00
Seefs
ea084e775e fix: claude system prompt overwrite 2025-09-20 00:22:54 +08:00
joesonshaw
b4a6721948 Merge branch 'QuantumNous:main' into main 2025-09-19 23:31:43 +08:00
creamlike1024
41be436c04 Merge branch 'feitianbubu-pr/vidu-add-first-end-reference-video' 2025-09-19 22:47:29 +08:00
feitianbubu
b73b16e102 feat: vidu video add starEnd and reference gen video show type 2025-09-19 18:54:48 +08:00
feitianbubu
8f9960bcc7 feat: vidu video add starEnd and reference gen video 2025-09-19 18:54:45 +08:00
feitianbubu
3c70617060 feat: vidu video support multi images 2025-09-19 18:54:40 +08:00
JoeyLearnsToCode
ec9903e640 feat: jump between section on channel edit page 2025-09-19 18:11:47 +08:00
F。
3a98ae3f70 改进"侧边栏"权限控制-1
This reverts commit d798db5953906aa5ff76cf6f2b641eb204d279b0.
2025-09-19 16:24:37 +08:00
F。
1894ddc786 改进"侧边栏"权限控制 2025-09-19 16:24:31 +08:00
F。
f23be16e98 修复"边栏"权限控制问题 2025-09-19 16:24:27 +08:00
F。
b882dfa8f6 修复"边栏"隐藏后无法即时生效问题 2025-09-19 16:24:23 +08:00
CaIon
d491cbd3d2 feat: update labels for ratio settings to clarify model support 2025-09-19 14:23:08 +08:00
CaIon
334ba555fc fix: cast option.Value to string for ratio updates 2025-09-19 14:21:32 +08:00
CaIon
ba632d0b4d CI 2025-09-19 14:20:35 +08:00
CaIon
b5d3e87ea2 Merge branch 'alpha' 2025-09-19 14:20:15 +08:00
wzxjohn
8d92ce38ed fix(relay): wrong key param while enable sse 2025-09-19 11:22:03 +08:00
joesonshaw
6c0b1681f9 fix(relay-xunfei): 修复讯飞渠道无法使用问题 #1740
将连接延迟关闭逻辑调整到协程中执行,防止在完全接收到所有数据前提前关闭
2025-09-19 10:49:47 +08:00
Calcium-Ion
f22ea6e0a8 Merge pull request #1834 from QuantumNous/gemini-embedding-001
feat: 支持 gemini-embedding-001
2025-09-19 00:40:40 +08:00
creamlike1024
9f1ab16aa5 feat: 支持 gemini-embedding-001 2025-09-19 00:24:01 +08:00
Seefs
0dd475d2ff Merge pull request #1833 from seefs001/feature/deepseek-claude-code
fix: deepseek claude response
2025-09-18 16:37:51 +08:00
Seefs
dd374cdd9b feat: deepseek claude endpoint 2025-09-18 16:32:29 +08:00
Calcium-Ion
daf3ef9848 Merge pull request #1832 from seefs001/feature/deepseek-claude-code
feat: deepseek claude endpoint
2025-09-18 16:30:01 +08:00
Seefs
23ee0fc3b4 feat: deepseek claude endpoint 2025-09-18 16:19:44 +08:00
Calcium-Ion
08638b18ce Merge pull request #1831 from seefs001/fix/kimi-claude-code
fix: kimi claude code
2025-09-18 16:15:41 +08:00
Seefs
d331f0fb2a fix: kimi claude code 2025-09-18 16:14:25 +08:00
CaIon
4b98fceb6e CI 2025-09-18 13:53:58 +08:00
Calcium-Ion
ef63416098 Merge commit from fork
feat: implement SSRF protection settings and update related references
2025-09-18 13:41:44 +08:00
CaIon
50a432180d feat: add experimental IP filtering for domains and update related settings 2025-09-18 13:40:52 +08:00
CaIon
2ea7634549 Merge branch 'main' into ssrf
# Conflicts:
#	service/cf_worker.go
2025-09-18 13:29:11 +08:00
MyPrototypeWhat
10da082412 refactor: Enhance UserArea dropdown positioning with useRef
- Added useRef to manage dropdown positioning in UserArea component.
- Wrapped Dropdown in a div with a ref to ensure correct popup container.
- Minor adjustments to maintain existing functionality and styling.
2025-09-18 12:01:35 +08:00
creamlike1024
31c8ead1d4 feat: 移除多余的说明文本 2025-09-17 23:54:34 +08:00
creamlike1024
00f4594062 fix: use u.Hostname() instead of u.Host to avoid ipv6 host parse failed 2025-09-17 23:47:59 +08:00
creamlike1024
467e584359 feat: 添加域名启用ip过滤开关 2025-09-17 23:46:04 +08:00
creamlike1024
f635fc3ae6 feat: remove ValidateURLWithDefaults 2025-09-17 23:29:18 +08:00
creamlike1024
168ebb1cd4 feat: ssrf支持域名和ip黑白名单过滤模式 2025-09-17 15:41:21 +08:00
creamlike1024
b7bc609a7a feat: 添加域名和ip过滤模式设置 2025-09-16 22:40:40 +08:00
RixAPI
4b98773e9a 优化渠道测试
增加并发支持
2025-09-16 20:03:10 +08:00
Calcium-Ion
046c8b27b6 Merge pull request #1816 from QuantumNous/fix/setup-err-display
🛠️ fix: Align setup API errors to HTTP 200 with {success:false, message}
2025-09-16 17:43:57 +08:00
t0ng7u
4be61d00e4 🛠️ fix: Align setup API errors to HTTP 200 with {success:false, message}
Unify the setup initialization endpoint’s error contract to match the rest
of the project and keep the frontend unchanged.

Changes
- controller/setup.go: Return HTTP 200 with {success:false, message} for all
  predictable errors in POST /api/setup, including:
  - already initialized
  - invalid payload
  - username too long
  - password mismatch
  - password too short
  - password hashing failure
  - root user creation failure
  - option persistence failures (SelfUseModeEnabled, DemoSiteEnabled)
  - setup record creation failure
- web/src/components/setup/SetupWizard.jsx: Restore catch handler to the
  previous generic toast (frontend logic unchanged).
- web/src/helpers/utils.jsx: Restore the original showError implementation
  (no Axios response.data parsing required).

Why
- Keep API behavior consistent across endpoints so the UI can rely on the
  success flag and message in the normal .then() flow instead of falling
  into Axios 4xx errors that only show a generic "400".

Impact
- UI now displays specific server messages during initialization without
  frontend adaptations.
- Note: clients relying solely on HTTP status codes for error handling
  should inspect the JSON body (success/message) instead.

No changes to the happy path; initialization success responses are unchanged.
2025-09-16 17:21:22 +08:00
wzxjohn
f2e9fd7afb fix(relay): wrong URL for claude model in GCP Vertex AI 2025-09-16 17:18:32 +08:00
t0ng7u
4ac7d94026 Merge remote-tracking branch 'origin/alpha' into alpha 2025-09-16 16:56:26 +08:00
t0ng7u
9af71caf73 🛠️ fix: Align setup API errors to HTTP 200 with {success:false, message}
Unify the setup initialization endpoint’s error contract to match the rest
of the project and keep the frontend unchanged.

Changes
- controller/setup.go: Return HTTP 200 with {success:false, message} for all
  predictable errors in POST /api/setup, including:
  - already initialized
  - invalid payload
  - username too long
  - password mismatch
  - password too short
  - password hashing failure
  - root user creation failure
  - option persistence failures (SelfUseModeEnabled, DemoSiteEnabled)
  - setup record creation failure
- web/src/components/setup/SetupWizard.jsx: Restore catch handler to the
  previous generic toast (frontend logic unchanged).
- web/src/helpers/utils.jsx: Restore the original showError implementation
  (no Axios response.data parsing required).

Why
- Keep API behavior consistent across endpoints so the UI can rely on the
  success flag and message in the normal .then() flow instead of falling
  into Axios 4xx errors that only show a generic "400".

Impact
- UI now displays specific server messages during initialization without
  frontend adaptations.
- Note: clients relying solely on HTTP status codes for error handling
  should inspect the JSON body (success/message) instead.

No changes to the happy path; initialization success responses are unchanged.
2025-09-16 16:55:35 +08:00
Xyfacai
91e57a4c69 feat: jimeng kling 支持new api 嵌套中转 2025-09-16 16:28:27 +08:00
Calcium-Ion
45a6a779e5 Merge pull request #1814 from ShibaInu64/fix/volcengine-image-model
fix: VolcEngine渠道-图片生成 API-渠道测试报错
2025-09-16 15:36:56 +08:00
huanghejian
49c7a0dee5 Merge branch 'main' into fix/volcengine-image-model 2025-09-16 14:58:59 +08:00
huanghejian
956244c742 fix: VolcEngine doubao-seedream-4-0-250828 2025-09-16 14:30:12 +08:00
Calcium-Ion
752dc11dd4 Merge pull request #1813 from QuantumNous/fix1797
fix: openai responses api 未统计图像生成调用计费
2025-09-16 14:07:14 +08:00
creamlike1024
17be7c3b45 fix: imageGenerationCall involves billing 2025-09-16 13:02:15 +08:00
creamlike1024
11cf70e60d fix: openai responses api 未统计图像生成调用计费 2025-09-16 12:47:59 +08:00
somnifex
f19b5b8680 chore: 删除历史文件 2025-09-16 08:58:19 +08:00
somnifex
69a88a0563 feat: 添加ollama聊天流处理和非流处理功能 2025-09-16 08:58:06 +08:00
somnifex
1dd78b83b7 fix: 修复ollamaChatHandler中ReasoningContent字段的赋值逻辑 2025-09-16 08:54:34 +08:00
somnifex
62549717e0 fix: 添加对Thinking字段的处理逻辑,确保推理内容正确传递 2025-09-16 08:51:29 +08:00
somnifex
4eeca081fe fix: 修复图像URL处理逻辑,确保正确生成base64数据 2025-09-16 08:13:28 +08:00
somnifex
9d952e0d78 fix: 修复ollamaChatHandler中的FinishReason字段赋值逻辑 2025-09-15 23:43:39 +08:00
somnifex
f7d393fc72 refactor: 简化请求转换函数和流处理逻辑 2025-09-15 23:41:09 +08:00
somnifex
176fd6eda1 fix: 优化ollamaStreamHandler中的停止和最终使用响应逻辑 2025-09-15 23:23:53 +08:00
somnifex
7d6ba52d85 refactor: 更新请求转换逻辑,优化工具调用解析 2025-09-15 23:15:46 +08:00
somnifex
fc38c480a1 fix: 优化ollamaChatStreamChunk结构体字段格式 2025-09-15 23:09:10 +08:00
somnifex
51c4cd9ab5 feat: 重构ollama渠道请求 2025-09-15 23:01:14 +08:00
QuentinHsu
dfa27f3412 feat: add jsconfig.json and configure path aliases 2025-09-15 22:30:41 +08:00
QuentinHsu
e34b5def60 feat: add date range preset constants and use them in the log filter 2025-09-15 21:59:25 +08:00
Xyfacai
63f94e7669 fix: 非openai 渠道使用 SystemPrompt 设置会panic 2025-09-15 19:38:31 +08:00
IcedTangerine
18a385f817 Merge pull request #1805 from feitianbubu/pr/jimeng-video-3.0
feat: 支持即梦视频3.0,支持文生图, 图生图, 首尾帧生图
2025-09-15 17:12:28 +08:00
Calcium-Ion
8e95d338b5 Merge pull request #1807 from QuantumNous/fix-stripe-successurl
fix: stripe支付成功未正确跳转
2025-09-15 16:24:20 +08:00
creamlike1024
f236785ed5 fix: stripe支付成功未正确跳转 2025-09-15 16:22:37 +08:00
feitianbubu
f3e220b196 feat: jimeng video 3.0 req_key convert 2025-09-15 15:54:13 +08:00
feitianbubu
33bf267ce8 feat: 支持即梦视频3.0,新增10s(frames=241)支持 2025-09-15 15:10:39 +08:00
DD
274872b8e5 add submodel icon 2025-09-15 14:31:31 +08:00
DD
cab562276d Merge branch 'main' of github.com:danding5/new-api
# Conflicts:
#	relay/relay_adaptor.go
2025-09-15 14:31:06 +08:00
IcedTangerine
05c2dde38f Merge pull request #1698 from QuantumNous/imageratio-and-audioratio-edit
feat: 图像倍率,音频倍率和音频补全倍率配置
2025-09-15 14:20:05 +08:00
creamlike1024
0ee5670be6 Merge branch 'alpha' into imageratio-and-audioratio-edit 2025-09-15 14:12:24 +08:00
Xyfacai
9790e2c4f6 fix: gemini support webp file 2025-09-15 01:01:48 +08:00
Calcium-Ion
4f760a8d40 Merge pull request #1799 from seefs001/fix/setting
fix: settings
2025-09-14 13:01:09 +08:00
Seefs
8563eafc57 fix: settings 2025-09-14 12:59:44 +08:00
CaIon
72d5b35d3f feat: implement SSRF protection settings and update related references 2025-09-13 18:15:03 +08:00
Calcium-Ion
7d71f467d9 Merge pull request #1794 from seefs001/fix/veo3
fix veo3 adapter
2025-09-13 17:33:18 +08:00
Seefs
aea732ab92 Merge pull request #1795 from seefs001/fix/veo3
Fix/veo3
2025-09-13 16:31:55 +08:00
Seefs
da6f24a3d4 fix veo3 adapter 2025-09-13 16:26:14 +08:00
CaIon
28ed42130c fix: update references from setting to system_setting for ServerAddress 2025-09-13 15:27:41 +08:00
Seefs
96215c9fd5 Merge pull request #1792 from QuantumNous/alpha
veo
2025-09-13 13:16:33 +08:00
Seefs
6628fd9181 Merge branch 'main' into alpha 2025-09-13 13:14:34 +08:00
Seefs
a3b8a1998a Merge pull request #1659 from Sh1n3zZ/feat-vertex-veo
feat: vertex veo (#1450)
2025-09-13 13:11:02 +08:00
Seefs
6a34d365ec Merge branch 'alpha' into feat-vertex-veo 2025-09-13 13:10:39 +08:00
CaIon
406a3e4dca Merge remote-tracking branch 'origin/main' 2025-09-13 12:54:49 +08:00
CaIon
c1d7ecdeec fix(adaptor): correct VertexKeyType condition in SetupRequestHeader 2025-09-13 12:53:41 +08:00
CaIon
6451158680 Revert "feat: gemini-2.5-flash-image-preview 文本和图片输出计费"
This reverts commit e732c58426.
2025-09-13 12:53:28 +08:00
creamlike1024
0bd4b34046 Merge branch 'feitianbubu-pr/add-jimeng-video-images' 2025-09-13 09:57:01 +08:00
feitianbubu
f14b06ec3a feat: jimeng video add images 2025-09-12 22:43:08 +08:00
feitianbubu
6ed775be8f refactor: use common taskSubmitReq 2025-09-12 22:43:03 +08:00
CaIon
b712279b2a feat(i18n): update TOTP verification message with configuration details 2025-09-12 21:53:21 +08:00
CaIon
1bffe3081d feat(settings): 移除单位美元额度设置项,为后续修改作准备 2025-09-12 21:14:10 +08:00
CaIon
cfebe80822 Merge remote-tracking branch 'origin/main' 2025-09-12 19:54:46 +08:00
CaIon
17e697af8f feat(i18n): add translations for pricing terms in English 2025-09-12 19:54:02 +08:00
CaIon
01b35bb667 Merge remote-tracking branch 'origin/main' 2025-09-12 19:29:40 +08:00
CaIon
d8410d2f11 feat(payment): add payment settings configuration and update payment methods handling 2025-09-12 19:29:34 +08:00
CaIon
e68eed3d40 feat(channel): add support for Vertex AI key type configuration in settings 2025-09-12 14:06:09 +08:00
Calcium-Ion
04cc668430 Merge pull request #1784 from Husky-Yellow/fix/1773
fix: UI 未对齐问题
2025-09-12 12:39:29 +08:00
Calcium-Ion
5d76e16324 Merge pull request #1780 from ShibaInu64/feature/support-amazon-nova
feat: support amazon nova model
2025-09-12 12:38:44 +08:00
Zhaokun Zhang
b6c547ae98 fix: UI 未对齐问题 2025-09-11 21:34:49 +08:00
CaIon
93adcd57d7 fix(responses): allow pass-through body for specific channel settings. (close #1762) 2025-09-11 21:02:12 +08:00
Calcium-Ion
e813da59cc Merge pull request #1775 from QuantumNous/alpha
Alpha
2025-09-11 18:47:35 +08:00
Xyfacai
b25ac0bfb6 fix: 预扣额度使用 relay info 传递 2025-09-11 16:04:32 +08:00
feitianbubu
465830945b fix: get video task err when Content-Type=json 2025-09-11 12:53:19 +08:00
huanghejian
70c27bc662 feat: improve nova config 2025-09-11 12:31:43 +08:00
creamlike1024
db6a788e0d fix: 优化 ImageRequest 的 JSON 序列化,避免覆盖合并 ExtraFields 2025-09-11 12:28:57 +08:00
huanghejian
e3bc40f11b pref: support amazon nova 2025-09-11 12:17:16 +08:00
heimoshuiyu
3e9be07db4 feat: add thousand separators to token display in dashboard 2025-09-11 10:34:51 +08:00
huanghejian
684caa3673 feat: amazon.nova-premier-v1:0 2025-09-11 10:01:54 +08:00
huanghejian
47aaa695b2 feat: support amazon nova 2025-09-10 20:30:00 +08:00
Xyfacai
cda73a2ec5 fix: dalle log 显示张数 N 2025-09-10 19:53:32 +08:00
DD
a12ed5709e merge 2025-09-10 19:11:58 +08:00
DD
78b0f8905b merge 2025-09-10 18:37:55 +08:00
DD
42d29756a0 Merge branches 'main' and 'main' of github.com:danding5/new-api
# Conflicts:
#	common/api_type.go
#	constant/api_type.go
#	constant/channel.go
#	relay/relay_adaptor.go
#	web/src/constants/channel.constants.js
2025-09-10 18:33:42 +08:00
Xyfacai
27a0a447d0 fix: err 如果是 newApiErr 则保留 2025-09-10 15:31:35 +08:00
Xyfacai
fcdfd027cd fix: openai 格式请求 claude 没计费 create cache token 2025-09-10 15:30:23 +08:00
Xyfacai
3f9698bb47 feat: dalle 自定义字段透传 2025-09-10 15:29:07 +08:00
undefinedcodezhong
99a8b5eef0 fix:Account Management Status 2025-09-10 10:41:44 +08:00
CaIon
041782c49e chore: remove PR branching strategy workflow file 2025-09-09 23:23:53 +08:00
Calcium-Ion
18077b6e87 Merge pull request #1767 from QuantumNous/copy-claude-header-from-upstream
fix: claude header was not set correctly
2025-09-09 23:21:57 +08:00
creamlike1024
c40a4f5444 fix: claude header was not set correctly 2025-09-09 23:18:07 +08:00
CaIon
028f0220dd Merge branch 'alpha'
# Conflicts:
#	README.md
2025-09-09 23:08:17 +08:00
DD
23e4249ebe merge 2025-09-08 17:33:15 +08:00
DD
511489db09 add submodel.ai 2025-09-08 16:21:21 +08:00
Calcium-Ion
a616aa3c89 Merge pull request #1692 from yunayj/alpha
修改claude system参数为数组,增加通用性
2025-09-08 14:55:48 +08:00
IcedTangerine
1c12c73496 Merge pull request #1761 from QuantumNous/openaitoclaude-improve
feat: 改进Claude响应转OpenAI响应
2025-09-07 23:39:30 +08:00
creamlike1024
b29efbde52 feat(relay-claude): mapping stop reason and send text delta on block start type
- convert claude stop reason "max_tokens" to openai "length"
- send content_block_start content text delta
2025-09-07 23:03:19 +08:00
Seefs
b7527eb80e Merge pull request #1677 from QuantumNous/gemini-2.5-flash-image-preview-billing
feat: gemini-2.5-flash-image-preview 文本和图片输出计费
2025-09-07 14:15:24 +08:00
Seefs
d05974fa3d Merge pull request #1754 from HynoR/fix/dtresp
fix: ensure the BuiltInTools entry exists before incrementing CallCount
2025-09-07 13:56:42 +08:00
HynoR
a77a88308a fix: enhance tool usage parsing with additional nil checks and error logging 2025-09-07 07:42:25 +08:00
t0ng7u
e5a5d2de7c 🐛 fix(models): export setActivePage to prevent tab-change TypeError
Context:
Clicking a vendor tab triggered “setActivePage is not a function” from ModelsTabs.jsx:43.

Root cause:
ModelsTabs expects `setActivePage` via props (spread from `useModelsData`), but the hook did not expose it in its return object, so the prop resolved to `undefined`.

Fix:
Export `setActivePage` from `useModelsData`’s return object so `ModelsTabs` receives a valid function.

Result:
Tab switching now correctly resets pagination to page 1 and reloads models without runtime errors.

Files:
- web/src/hooks/models/useModelsData.jsx

Test plan:
- Open the Models page
- Click different vendor tabs
- Verify no crash occurs and the list reloads with page reset to 1

Refs: web/src/components/table/models/ModelsTabs.jsx:43
2025-09-06 21:57:26 +08:00
HynoR
c0187d50ff fix: add error handling for missing built-in tools and validate response in stream handler 2025-09-05 13:58:24 +08:00
Seefs
3d0bf36981 Merge pull request #1749 from nekohy/feats-negative-number 2025-09-04 23:39:43 +08:00
Nekohy
e61c1dc738 fix: allow the negative number for override.go 2025-09-04 23:36:19 +08:00
CaIon
91a627ddfc fix(channel): implement per-channel locking to ensure thread-safe updates in multi-key mode 2025-09-03 15:52:54 +08:00
Calcium-Ion
3064ff093a Add request format conversion functionality
Updated the features list to include request format conversion functionality and adjusted the order of items.
2025-09-03 14:45:00 +08:00
CaIon
e2f736bd2d feat(readme): update format conversion feature details in README 2025-09-03 14:43:51 +08:00
CaIon
c6cf1b98f8 feat(option): enhance UpdateOption to handle various value types and improve validation 2025-09-03 14:30:25 +08:00
CaIon
56fc3441da feat(monitor_setting): implement automatic channel testing configuration 2025-09-03 14:00:52 +08:00
t0ng7u
ebaaecb9d9 🐛 fix(models-sync): allow sync when no conflicts selected
When syncing official models, clicking "Apply overwrite" with zero selected
conflict fields resulted in no request being sent and the modal simply closing.
This blocked creation of missing models/vendors even though the backend
supports an empty `overwrite` array and will still create missing items.

Changes:
- Remove the early-return guard in `UpstreamConflictModal.handleOk`
- Always call `onSubmit(payload)` even when `payload` is empty
- Keep closing behavior when the request succeeds

Behavior:
- Users can now proceed with upstream sync without selecting any conflict fields
- Missing models/vendors are created as expected
- Existing models are not overwritten unless fields are explicitly selected

Affected:
- web/src/components/table/models/modals/UpstreamConflictModal.jsx

Quality:
- Lint passes
- No breaking changes
- No visual/UI changes beyond the intended behavior

Test plan:
1) Open official models sync and trigger a conflicts preview
2) Click "Apply overwrite" without selecting any fields
3) Expect the sync to proceed and a success toast indicating created models
4) Re-try with some fields selected to confirm overwrites still work
2025-09-03 00:06:27 +08:00
t0ng7u
fa7ba4a390 🐛 fix(models sync): send correct overwrite payload and drop fallback
Ensure UpstreamConflictModal submits { overwrite: payload, locale } instead of spreading an array into an object
Remove numeric-key fallback from applyUpstreamOverwrite for simpler and explicit logic
Effect: selected fields are now actually updated; success message shows updated model count
Refs: backend SyncUpstreamModels expects overwrite: overwriteField[]
2025-09-02 19:07:17 +08:00
t0ng7u
29983e434f Merge remote-tracking branch 'origin/alpha' into alpha 2025-09-02 18:49:51 +08:00
t0ng7u
8c65264474 feat(sync): multi-language sync wizard, backend locale support, and conflict modal UX improvements
Frontend (web)
- ModelsActions.jsx
  - Replace “Sync Official” with “Sync” and open a new two-step SyncWizard.
  - Pass selected locale through to preview, sync, and overwrite flows.
  - Keep conflict resolution flow; inject locale into overwrite submission.

- New: models/modals/SyncWizardModal.jsx
  - Two-step wizard: (1) method selection (config-sync disabled for now), (2) language selection (en/zh/ja).
  - Horizontal, centered Radio cards; returns { option, locale } via onConfirm.

- UpstreamConflictModal.jsx
  - Add search input (model fuzzy search) and native pagination.
  - Column header checkbox now only applies to rows in the current filtered result.
  - Fix “Cannot access ‘filteredDataSource’ before initialization”.
  - Refactor with useMemo/useCallback; extract helpers to remove duplicated logic:
    - getPresentRowsForField, getHeaderState, applyHeaderChange
  - Minor code cleanups and stability improvements.

- i18n (en.json)
  - Add strings for the sync wizard and related actions (Sync, Sync Wizard, Select method/source/language, etc.).
  - Adjust minor translations.

Hooks
- useModelsData.jsx
  - Extend previewUpstreamDiff, syncUpstream, applyUpstreamOverwrite to accept options with locale.
  - Send locale via query/body accordingly.

Backend (Go)
- controller/model_sync.go
  - Accept locale from query/body and resolve i18n upstream URLs.
  - Add SYNC_UPSTREAM_BASE for upstream base override (default: https://basellm.github.io/llm-metadata).
  - Make HTTP timeouts/retries/limits configurable:
    - SYNC_HTTP_TIMEOUT_SECONDS, SYNC_HTTP_RETRY, SYNC_HTTP_MAX_MB
  - Add ETag-based caching and support both envelope and pure array JSON formats.
  - Concurrently fetch vendors and models; improve error responses with locale and source URLs.
  - Include source meta (locale, models_url, vendors_url) in success payloads.

Notes
- No breaking changes expected.
- Lint passes for touched files.
2025-09-02 18:49:37 +08:00
Seefs
cd4b75f492 Merge pull request #1733 from seefs001/fix/jsoneditor
fix: adjust column spans in JSONEditor for better layout  #1719
2025-09-02 18:32:36 +08:00
Seefs
faad6bcd0c fix: adjust column spans in JSONEditor for better layout #1719 2025-09-02 18:28:23 +08:00
Seefs
265a9ea78c Merge pull request #1724 from momomobinx/base
openai v1/models 完全兼容 解决接入trae时候的字段校验
2025-09-02 18:09:56 +08:00
Seefs
aeab08099b Merge branch 'alpha' into base 2025-09-02 18:08:39 +08:00
t0ng7u
d9f37d16f7 🎨 fix: sidebar skeleton background and icon spacing consistency
- Set sidebar skeleton background to use theme variable (--semi-color-bg-0) instead of hardcoded white for better dark mode compatibility
- Apply consistent background to both collapsed and expanded skeleton states
- Ensure sidebar container uses theme background when skeleton is loading
- Remove duplicate margin-right classes from skeleton wrapper components that conflicted with CSS definitions
- Simplify navigation text structure by removing unnecessary div wrappers to improve text truncation
- Add proper text layout styles for better truncation handling when menu items have long names
- Standardize icon-to-text spacing across all sidebar navigation items
2025-09-02 17:07:01 +08:00
xuebin
203abf4430 openai v1/models 完全兼容 解决接入trae时候的字段校验 2025-09-02 14:17:54 +08:00
creamlike1024
17024490e9 Merge branch 'feitianbubu-pr/opt-audio-usage' into alpha 2025-09-02 13:35:28 +08:00
feitianbubu
f7ae3621f4 feat: use audio token usage if return 2025-09-02 10:58:10 +08:00
t0ng7u
5cbd9da3f5 fix(web/layout): normalize HeaderBar -> headerbar (case) 2025-09-02 04:10:32 +08:00
t0ng7u
daffba3641 🤖 fix(web/layout): rename HeaderBar -> headerbar (case sensitive) 2025-09-02 04:03:19 +08:00
t0ng7u
860ab51434 🐛 fix(web/layout): explicitly import headerbar/index.jsx to resolve Linux build failure
The Linux/Vite build failed with:
“Could not resolve "./headerbar" from "src/components/layout/PageLayout.jsx"”

On Linux and with stricter ESM/rollup resolution, directory index files (index.jsx)
may not be auto-resolved reliably. Explicitly importing the index file ensures
cross-platform stability.

Changes:
- Update PageLayout import from "./headerbar" to "./headerbar/index.jsx"

Impact:
- Fixes build on Linux
- No runtime behavior changes

Verification:
- Linter passes for web/src/components/layout/PageLayout.jsx

Notes:
- Prefer explicit index file imports (and extensions) to avoid platform differences.
2025-09-02 03:54:32 +08:00
t0ng7u
1442666cc0 🌏 i18n: replace to correct punctuation mark 2025-09-02 03:42:31 +08:00
t0ng7u
5ac9ebdebb feat: Add skeleton loading states for sidebar navigation
Add comprehensive skeleton screen implementation for sidebar to improve loading UX, matching the existing headerbar skeleton pattern.

## Features Added
- **Sidebar skeleton screens**: Complete 1:1 recreation of sidebar structure during loading
- **Responsive skeleton layouts**: Different layouts for expanded (164×30px) and collapsed (44×44px) states
- **Skeleton component enhancements**: Extended SkeletonWrapper with new skeleton types (sidebar, button, sidebarNavItem, sidebarGroupTitle)
- **Minimum loading time**: Integrated useMinimumLoadingTime hook with 500ms duration for smooth UX

## Layout Specifications
- **Expanded nav items**: 164×30px with 8px horizontal margins and 3px vertical margins
- **Collapsed nav items**: 44×44px with 4px bottom margin and 8px horizontal margins
- **Collapse button**: 156×24px (expanded) / 36×24px (collapsed) with rounded corners
- **Container padding**: 12px top padding, 8px horizontal margins
- **Group labels**: 4px 15px 8px padding matching real sidebar-group-label styles

## Code Improvements
- **Refactored skeleton rendering**: Eliminated code duplication using reusable components (NavRow, CollapsedRow)
- **Configuration-driven sections**: Sections defined as config objects with title widths and item widths
- **Fixed width calculations**: Removed random width generation, using precise fixed widths per menu item
- **Proper CSS class alignment**: Uses real sidebar CSS classes (sidebar-section, sidebar-group-label, sidebar-divider)

## UI/UX Enhancements
- **Bottom-aligned collapse button**: Fixed positioning using margin-top: auto to stay at viewport bottom
- **Accurate spacing**: Matches real sidebar margins, padding, and spacing exactly
- **Skeleton stability**: Fixed width values prevent layout shifts during loading
- **Clean file structure**: Removed redundant HeaderBar.js export file

## Technical Details
- Extended SkeletonWrapper component with sidebar-specific skeleton types
- Integrated skeleton loading state management in SiderBar component
- Added support for collapsed state awareness in skeleton rendering
- Implemented precise dimension matching for pixel-perfect loading states

Closes: Sidebar skeleton loading implementation
2025-09-02 03:38:01 +08:00
t0ng7u
a47a37d315 🧹 refactor(db): remove legacy models/vendors index cleanup logic
- Delete dropIndexIfExists helper from `model/main.go`
- Remove all calls to dropIndexIfExists in `migrateDB` and `migrateDBFast`
- Drop related comments and MySQL-only DROP INDEX code paths
- Keep GORM AutoMigrate as the sole migration path for `Model` and `Vendor`

Why:
- Simplifies migrations and avoids destructive index drops at startup
- Prevents noisy MySQL 1091 errors and vendor-specific branches
- Aligns with composite unique indexes (uk_model_name_delete_at, uk_vendor_name_delete_at)

Impact:
- No expected runtime behavior change; schema remains managed by GORM
- Legacy single-column unique indexes (if any) will no longer be auto-dropped
- Safe across MySQL/PostgreSQL/SQLite; MySQL Chinese charset checks remain intact

Verification:
- Lint passed for `model/main.go`
- Confirmed no remaining `DROP INDEX` or `dropIndexIfExists` references
2025-09-02 02:24:17 +08:00
t0ng7u
fbc19abd28 feat(models-sync): official upstream sync with conflict resolution UI, opt‑out flag, and backend resiliency
Backend
- Add endpoints:
  - GET /api/models/sync_upstream/preview — diff preview (filters out models with sync_official = 0)
  - POST /api/models/sync_upstream — apply sync (create missing; optionally overwrite selected fields)
- Respect opt‑out: skip models with sync_official = 0 in both preview and apply
- Return detailed stats: created_models, created_vendors, updated_models, skipped_models, plus created_list / updated_list
- Add model.Model.SyncOfficial (default 1); auto‑migrated by GORM
- Make HTTP fetching robust:
  - Shared http.Client (connection reuse) with 3x exponential backoff retry
  - 10MB response cap; keep existing IPv4‑first for *.github.io
- Vendor handling:
  - New ensureVendorID helper (cache lookup → DB lookup → create), reduces round‑trips
  - Transactional overwrite to avoid partial updates
- Small cleanups and clearer helpers (containsField, coalesce, chooseStatus)

Frontend
- ModelsActions: add “Sync official” button with Popover (p‑2) explaining community contribution; loading = syncing || previewing; preview → conflict modal → apply flow
- New UpstreamConflictModal:
  - Per‑field columns (description/icon/tags/vendor/name_rule/status) with column‑level checkbox to select all
  - Cell with Checkbox + Tag (“Click to view differences”) and Popover (p‑2) showing Local vs Official values
  - Auto‑hide columns with no conflicts; responsive width; use native Semi Modal footer
  - Full i18n coverage
- useModelsData: add syncing/previewing states; new methods previewUpstreamDiff, applyUpstreamOverwrite, syncUpstream; refresh vendors/models after apply
- EditModelModal: add “Participate in official sync” switch; persisted as sync_official
- ModelsColumnDefs: add “Participate in official sync” column

i18n
- Add missing English keys for the new UI and messages; fix quoting issues

Refs
- Upstream metadata: https://github.com/basellm/llm-metadata
2025-09-02 02:04:22 +08:00
t0ng7u
1f111a163a feat(ratio-sync, ui): add built‑in “Official Ratio Preset” and harden upstream sync
Backend (controller/ratio_sync.go):
- Add built‑in official upstream to GetSyncableChannels (ID: -100, BaseURL: https://basellm.github.io)
- Support absolute endpoint URLs; otherwise join BaseURL + endpoint (defaults to /api/ratio_config)
- Harden HTTP client:
  - IPv4‑first with IPv6 fallback for github.io
  - Add ResponseHeaderTimeout
  - 3 attempts with exponential backoff (200/400/800ms)
- Validate Content-Type and limit response body to 10MB (safe decode via io.LimitReader)
- Robust parsing: support type1 ratio_config map and type2 pricing list
- Use net.SplitHostPort for host parsing
- Use float tolerance in differences comparison to avoid false mismatches
- Remove unused code (tryDirect) and improve warnings

Frontend:
- UpstreamRatioSync.jsx: auto-assign official endpoint to /llm-metadata/api/newapi/ratio_config-v1-base.json
- ChannelSelectorModal.jsx:
  - Pin the official source at the top of the list
  - Show a green “官方” tag next to the status
  - Refactor status renderer to accept the full record

Notes:
- Backward compatible; no API surface changes
- Official ratio_config reference: https://basellm.github.io/llm-metadata/api/newapi/ratio_config-v1-base.json
2025-09-01 23:43:39 +08:00
Seefs
b601d8fd7c Merge pull request #1680 from HynoR/fix/res
fix: update model name filtering to be case-sensitive
2025-09-01 21:04:27 +08:00
Seefs
e98ca000f2 Merge pull request #1712 from seefs001/feature/bark
feat: bark notification #1699
2025-09-01 20:28:12 +08:00
Seefs
5351c28af8 Merge pull request #1713 from seefs001/feature/channel_remark
feat: add channel remark #1710
2025-09-01 20:26:06 +08:00
Seefs
e174861b96 feat: add channel remark #1710 2025-09-01 16:23:29 +08:00
Seefs
247e029159 feat: bark notification #1699 2025-09-01 15:57:23 +08:00
IcedTangerine
5cfc133413 Merge pull request #1672 from feitianbubu/pr/fix-mysql-default-false
fix: prevent loop auto-migrate with bool default false
2025-08-31 15:06:37 +08:00
creamlike1024
c6f53e4cc8 fix: revert 3a3be21 2025-08-31 14:59:55 +08:00
creamlike1024
c8acbdb363 fix: update sidebar modules role check 2025-08-31 14:54:47 +08:00
creamlike1024
3a3be21366 fix(user): UpdateSelf 边栏权限检查和类型检查 2025-08-31 14:40:35 +08:00
creamlike1024
274da13a19 fix: add OptionMap RLock to GetStatus() 2025-08-31 14:28:02 +08:00
creamlike1024
153994fe45 Merge branch 'alpha' of github.com:x-Ai/new-api into x-Ai-alpha 2025-08-31 14:03:17 +08:00
t0ng7u
cdef6da9e9 🎨 style(go): format entire codebase
- Apply canonical Go formatting to all .go files
- No functional changes; whitespace/import/struct layout only
- Improves consistency, reduces diff noise, and aligns with standard tooling
2025-08-31 13:08:34 +08:00
t0ng7u
9127449a7a 🐛 fix(db): rename composite unique indexes to avoid drop/recreate on restart
- Model: rename `uk_model_name` -> `uk_model_name_delete_at`
  (composite on `model_name` + `deleted_at`)
- Vendor: rename `uk_vendor_name` -> `uk_vendor_name_delete_at`
  (composite on `name` + `deleted_at`)
- Keep legacy cleanup in `model/main.go` to drop old index names
  (`uk_model_name`, `model_name`, `uk_vendor_name`, `name`) for compatibility.

Result: idempotent GORM migrations and no unnecessary index churn on MySQL restarts.

Files:
- `model/model_meta.go`
- `model/vendor_meta.go`
2025-08-31 13:00:28 +08:00
F。
8809c44443 顶栏和侧边栏管理
增加用户体验
2025-08-31 07:07:40 +08:00
creamlike1024
d15718a87e feat: improve ratio update 2025-08-30 23:53:46 +08:00
creamlike1024
da5aace109 feat: 图像倍率,音频倍率和音频补全倍率配置 2025-08-30 23:28:09 +08:00
t0ng7u
6a87808612 🎨 chore(web): apply ESLint and Prettier auto-fixes (baseline)
- Ran: bun run eslint:fix && bun run lint:fix
- Inserted AGPL license header via eslint-plugin-header
- Enforced no-multiple-empty-lines and other lint rules
- Formatted code using Prettier v3 (@so1ve/prettier-config)
- No functional changes; formatting-only baseline across JS/JSX files
2025-08-30 21:15:10 +08:00
t0ng7u
105b86c660 🤓 chore: remove the useless tooltip component 2025-08-29 23:56:18 +08:00
t0ng7u
b8b66c3900 feat(pricing): add default vendor icons with LobeHub color support
- Add defaultVendorIcons mapping for major AI vendors
- Update getOrCreateVendor to automatically set vendor icons
- Add getDefaultVendorIcon helper function
- Support LobeHub icons Color variants (e.g., Claude.Color, Gemini.Color)
- Fix issue where default vendors were created without icons

This ensures that when new models are encountered, their vendors
will be created with appropriate colored icons for better UI display.

Affected vendors include:
- OpenAI, Anthropic, Google, Moonshot, 智谱, 阿里巴巴
- DeepSeek, MiniMax, 百度, 讯飞, 腾讯, Cohere
- Cloudflare, 360, 零一万物, Jina, Mistral, xAI
- Meta, 字节跳动, 快手, 即梦, Vidu, Microsoft/Azure
2025-08-29 23:42:33 +08:00
creamlike1024
bc5b9a5506 Merge branch 'feitianbubu-pr/fix-kling-text2image' into alpha 2025-08-29 22:47:21 +08:00
creamlike1024
9c798dcd16 Merge branch 'pr/fix-kling-text2image' of github.com:feitianbubu/new-api into feitianbubu-pr/fix-kling-text2image 2025-08-29 22:45:59 +08:00
creamlike1024
f5b8abc3f3 Merge branch 'feitianbubu-pr/kling-new-params' into alpha 2025-08-29 22:40:23 +08:00
creamlike1024
09cc127121 Merge branch 'pr/kling-new-params' of github.com:feitianbubu/new-api into feitianbubu-pr/kling-new-params 2025-08-29 22:39:54 +08:00
t0ng7u
ac67d50616 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-29 22:36:39 +08:00
t0ng7u
86964bb426 🎨 feat(ui): enhance pricing components with improved icons and responsive design
- Replace copy button icon from semi-ui IconCopy to lucide-react Copy in PricingCardView
- Add conditional tooltip functionality to SelectableButtonGroup that only shows when text overflows
- Implement responsive table column behavior in PricingTableColumns with mobile-aware fixed positioning
- Use DOM-based overflow detection (scrollWidth vs clientWidth) for better performance
- Apply useIsMobile hook to conditionally set fixed: 'right' only on desktop devices

These changes improve user experience across different screen sizes and provide more consistent iconography throughout the pricing interface.
2025-08-29 22:36:05 +08:00
IcedTangerine
c05dc07666 Merge pull request #1564 from feitianbubu/pr/init-default-vendor
feat: init default vendor
2025-08-29 19:48:20 +08:00
yunayj
af94e11c7d 修改claude system参数为数组格式,提升API兼容性 2025-08-29 19:06:01 +08:00
t0ng7u
0f86c4df9e chore: Increase default page size from 10 to 100 items in model pricing views
This commit updates the default pagination settings across the model pricing
components to improve user experience by reducing the need for frequent
page navigation when browsing large model catalogs.

Changes made:
- Update initial pageSize state from 10 to 100 in useModelPricingData hook
- Set defaultPageSize to 100 in PricingTable pagination configuration
- Increase default skeletonCount from 10 to 100 in PricingCardSkeleton

Files modified:
- web/src/hooks/model-pricing/useModelPricingData.jsx
- web/src/components/table/model-pricing/view/table/PricingTable.jsx
- web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx

This change affects both card and table view modes of the model pricing page,
ensuring consistent pagination behavior across different display formats.
2025-08-29 18:30:21 +08:00
t0ng7u
5f0db18d3a 📱 feat(pricing-header): show only search/copy/filter on mobile; hide vendor intro
- Mobile (isMobile=true): render SearchActions (search, copy, filter) only; hide vendor intro card
- Keep PricingFilterModal available on mobile for filtering
- Desktop/Non-mobile: unchanged behavior (vendor intro remains visible)
- Improves small-screen UX by reducing visual clutter and prioritizing primary actions

Files:
- web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx

Notes:
- Added `SearchActions` import and conditional rendering
- No breaking changes; no styling changes required
2025-08-29 17:26:51 +08:00
t0ng7u
919e6937ee 🎨 feat: Implement responsive design for SelectableButtonGroup component
This commit introduces a comprehensive responsive design system for the SelectableButtonGroup component that adapts to container width changes, particularly optimized for dynamic sidebar layouts.

## Key Features

### 1. Container Width Detection
- Added `useContainerWidth` hook using ResizeObserver API
- Real-time container width monitoring for responsive calculations
- Automatic layout adjustments based on available space

### 2. Intelligent Column Layout
Implements a 4-tier responsive system:
- **≤280px**: 1 column + tags (mobile portrait)
- **281-380px**: 2 columns + tags (narrow screens)
- **381-460px**: 3 columns - tags (general case, prioritizes readability)
- **>460px**: 3 columns + tags (wide screens, full feature display)

### 3. Dynamic Tag Visibility
- Tags automatically hide in medium-width containers (381-460px) to improve text readability
- Tags show in narrow and wide containers where space allows for optimal UX
- Responsive threshold ensures content clarity across all viewport sizes

### 4. Adaptive Grid Spacing
- Compact spacing `[4,4]` for containers ≤400px
- Standard spacing `[6,6]` for larger containers
- Additional `.sbg-compact` CSS class for fine-tuned styling in narrow layouts

### 5. Sidebar Integration
- Perfectly compatible with dynamic sidebar width: `clamp(280px, 24vw, 520px)`
- Automatically adjusts as sidebar scales with viewport changes
- Maintains optimal button density and information display at all sizes

## Technical Implementation

- **Hook**: `useContainerWidth.js` - ResizeObserver-based width detection
- **Component**: Enhanced `SelectableButtonGroup.jsx` with responsive logic
- **Styling**: Added `.sbg-compact` mode in `index.css`
- **Performance**: Efficient span calculation using `Math.floor(24 / perRow)`

## Benefits

- Improved UX across all screen sizes and sidebar configurations
- Better text readability through intelligent tag hiding
- Seamless integration with existing responsive sidebar system
- Maintains component functionality while optimizing space utilization

Closes: Responsive design implementation for model marketplace sidebar components
2025-08-29 17:05:49 +08:00
feitianbubu
64e23f02f7 feat: add kling video new params support
for example: image_tail,negative_prompt,camera_control
2025-08-29 11:51:41 +08:00
CaIon
fbe7f35a25 feat(token_counter): add mime type extraction for base64 encoded data 2025-08-28 17:30:53 +08:00
CaIon
8cd0150a75 fix(channel): ensure thread-safe polling with channel-specific lock 2025-08-28 16:34:23 +08:00
Xyfacai
839aa401f0 fix: 修复openai error 错误被覆盖 2025-08-28 16:08:51 +08:00
HynoR
4055777110 fix: update model name filtering to be case-sensitive 2025-08-28 15:16:25 +08:00
CaIon
b3a99a2625 Revert "refactor: replace DeepCopy with Copy for request handling consistency"
This reverts commit 872f7a9648.
2025-08-28 15:11:55 +08:00
CaIon
872f7a9648 refactor: replace DeepCopy with Copy for request handling consistency 2025-08-28 14:57:47 +08:00
CaIon
b0c703935f fix(pre_consume_quota): improve error messages for insufficient user quota 2025-08-28 13:57:16 +08:00
CaIon
621d2b0b6a refactor: replace json.Marshal with common.Marshal for consistency and error handling 2025-08-28 13:51:07 +08:00
creamlike1024
e69520b7fb Merge branch 'iszcz-alpha' into alpha 2025-08-27 23:27:31 +08:00
creamlike1024
4b968d03a1 fix(relay): initialize TaskRelayInfo 2025-08-27 23:26:51 +08:00
creamlike1024
edc6679140 Merge branch 'alpha' of github.com:iszcz/new-api into iszcz-alpha 2025-08-27 22:26:15 +08:00
creamlike1024
e732c58426 feat: gemini-2.5-flash-image-preview 文本和图片输出计费 2025-08-27 21:30:52 +08:00
Sh1n3zZ
81e29aaa3d feat: vertex veo (#1450) 2025-08-27 18:06:47 +08:00
CaIon
c5a1cbe755 fix(token_counter): update token counting logic to handle media token conditions 2025-08-27 17:11:33 +08:00
CaIon
35218609d9 fix(image): add error handling for empty base64 string in image decoding 2025-08-27 16:38:32 +08:00
feitianbubu
7629ad553a fix: prevent loop auto-migrate with bool default false 2025-08-27 12:33:56 +08:00
CaIon
7ddf3a112c fix(relay-gemini): update media handling in candidate content processing 2025-08-27 12:14:50 +08:00
t0ng7u
034094c2d2 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-27 11:59:41 +08:00
t0ng7u
65ed6d9d5b feat(playground): add search functionality to group selector
- Add `filter={selectFilter}` to enable search filtering in group Select component
- Add `autoClearSearchValue={false}` to preserve search input value
- Maintain consistency with existing model selector search behavior
- Improve user experience by allowing quick filtering of group options

Files modified:
- web/src/components/playground/SettingsPanel.jsx
2025-08-27 11:57:12 +08:00
t0ng7u
4524f90ebd 🐛 fix(playground): set Chat error text color to white to match Semi UI
- Update error-state rendering to use white text in the playground chat
- Remove Typography.Text `type="danger"` and the red background for consistency with official behavior
- Preserve layout and other message states (loading/success/system) unchanged
- No linter issues introduced

Files touched:
- web/src/components/playground/MessageContent.jsx
2025-08-27 11:50:43 +08:00
creamlike1024
33dd326007 Merge branch 'AAEE86-alpha' into alpha 2025-08-27 01:30:20 +08:00
creamlike1024
95b487c51e feat: add disable cache middleware 2025-08-27 01:30:01 +08:00
creamlike1024
fcb03392d1 Merge branch 'alpha' of github.com:AAEE86/new-api into AAEE86-alpha 2025-08-27 01:28:37 +08:00
CaIon
64a6168092 fix(model_ratio): update return value logic for gemini-2.5-flash-lite 2025-08-26 23:01:00 +08:00
CaIon
6a6edaa7cf Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-26 20:38:32 +08:00
Calcium-Ion
a95d70cf93 Merge pull request #1579 from wzxjohn/hotfix/openai_claude_convert
fix(adaptor): missing first text delta while convert OpenAI to Claude
2025-08-26 20:37:54 +08:00
Calcium-Ion
3e01dc81ec Merge pull request #1652 from HynoR/feat/ds3.1
feat: adapt Volcengine adaptor for deepseek3.1 with thinking mode
2025-08-26 20:35:29 +08:00
CaIon
e087c9fe9e fix: update web search handling and request structure in adaptor and openai_request 2025-08-26 18:15:18 +08:00
CaIon
33d601db82 fix: update error types for upstream errors and JSON marshal failure 2025-08-26 16:26:56 +08:00
CaIon
eef73e3699 fix: update PromptTokens assignment logic in relay_responses 2025-08-26 14:21:10 +08:00
CaIon
1cc07546cb feat: replace pcopy with jinzhu/copier for deep copy functionality 2025-08-26 13:40:41 +08:00
CaIon
e23f01f8d5 fix: Invalid type for 'input[x].summary': expected an array of objects, but got null instead 2025-08-26 13:17:31 +08:00
CaIon
a3c2b28d6a fix: ensure reasoning is not nil before setting effort in OpenAI responses 2025-08-25 22:46:45 +08:00
iszcz
289ed24899 task_relay_info 2025-08-25 18:01:10 +08:00
IcedTangerine
98db907680 Merge pull request #1549 from QuantumNous/update-openai-websearch-price
feat: update openai websearch price
2025-08-25 16:52:57 +08:00
AAEE86
b1cc9050ff fix: 添加接口速率限制中间件,优化验证码输入框交互体验 2025-08-25 15:20:04 +08:00
AAEE86
dc4f5750af feat(channel): 添加2FA验证后查看渠道密钥功能
- 新增接口通过2FA验证后获取渠道密钥
- 统一实现2FA验证码和备用码的验证逻辑
- 记录用户查看密钥的操作日志
- 编辑渠道弹窗新增查看密钥按钮,触发2FA验证模态框
- 使用TwoFactorAuthModal进行验证码输入及验证
- 验证成功后弹出渠道密钥展示窗口
- 对渠道编辑模态框的状态进行了统一重置优化
- 添加相关国际化文案支持密钥查看功能
2025-08-25 14:45:48 +08:00
CaIon
d374a22b70 feat: support qwen-image-edit 2025-08-25 14:33:12 +08:00
CaIon
595ed6b40e Merge remote-tracking branch 'origin/feat/dalle-extra' into alpha
# Conflicts:
#	dto/dalle.go
2025-08-25 14:20:54 +08:00
CaIon
c9f5b1de1a fix: improve model ratio handling for reserved models in getHardcodedCompletionModelRatio 2025-08-25 11:59:55 +08:00
CaIon
522f2d920b Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-25 11:52:57 +08:00
CaIon
bef59929db fix: update model ratio handling for GPT versions 2025-08-25 11:52:45 +08:00
t0ng7u
b27b9a1098 🍎 chore(EditChannelModal.jsx): Format code file 2025-08-25 11:32:28 +08:00
t0ng7u
70de3819e8 🧹 chore: remove useless i18n file 2025-08-25 11:29:13 +08:00
同語
af18dec46b 🤓 feat: When adding or editing channels, add the function of clicking the added model to copy its name
Merge pull request #1648 from AAEE86/alpha
2025-08-25 11:26:45 +08:00
CaIon
43efc2161a feat: add SaveWithoutKey method to Channel and update status saving logic 2025-08-25 11:20:16 +08:00
CaIon
caaa988c87 fix: correct logic for handling nil OpenAI error codes. (close #1609) 2025-08-25 11:19:32 +08:00
HynoR
ee6dd9179b feat: adapt Volcengine adaptor for deepseek3.1 model with thinking suffix 2025-08-25 11:16:43 +08:00
Calcium-Ion
f96a733430 Merge pull request #1647 from aotsukiqx/main
fix: update channel.go fix #1641
2025-08-25 10:25:06 +08:00
CaIon
de23ccd234 feat: update ali image response handling 2025-08-24 22:50:45 +08:00
AAEE86
da516af837 feat: 在新增&编辑渠道时添加点击模型复制名称功能
(cherry picked from commit c4935f392f)
2025-08-24 22:50:29 +08:00
CaIon
7fbf9c4851 feat: enhance image request handling and add async support 2025-08-24 21:52:56 +08:00
t0ng7u
808f5c481e 💄 style(topup): align container width with PersonalSetting (w-full max-7xl)
- Set TopUp page outer wrapper to "w-full max-w-7xl mx-auto px-2"
  to match PersonalSetting and ensure consistent layout width and padding.
- No functional changes; UI-only adjustment.
- Lint checks passed.
- Verified that pages/TopUp only re-exports the component (no extra wrapper).

Affected: web/src/components/topup/index.jsx
2025-08-24 17:29:42 +08:00
t0ng7u
6dcf954bfe 🕒 feat(ui): standardize Timelines to left mode and unify time display
- Switch Semi UI Timeline to mode="left" in:
  - web/src/components/layout/NoticeModal.jsx
  - web/src/components/dashboard/AnnouncementsPanel.jsx
- Show both relative and absolute time in the `time` prop (e.g. "3 days ago 2025-02-18 10:30")
- Move auxiliary description to the `extra` prop and remove duplicate rendering from content area
- Keep original `extra` data intact; compute and pass:
  - `time`: absolute time (yyyy-MM-dd HH:mm)
  - `relative`: relative time (e.g., "3 days ago")
- Update data assembly to expose `time` and `relative` without overwriting `extra`:
  - web/src/components/dashboard/index.jsx
- No i18n changes; no linter errors introduced

Why: Aligns Timeline layout across the app and clarifies time context by combining relative and absolute timestamps while preserving auxiliary notes via `extra`.
2025-08-24 17:23:03 +08:00
aotsuki
cb6fa7d46d Update channel.go 2025-08-24 11:39:38 +08:00
t0ng7u
1e3621833f Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-24 10:52:54 +08:00
t0ng7u
eedb57b2c6 📱 feat(header): add mobile filter skeleton; align mobile tag layout
- Add mobile filter-button placeholder in skeleton when `isMobile` is true
- Plumb `isMobile` from `PricingVendorIntroWithSkeleton` to `PricingVendorIntroSkeleton`
- Rename skeleton key from 'button' to 'copy-button' for consistency
- Neutralize copy-button skeleton color to match input (use neutral palette)
- Keep “Total x models” tag inline with title on mobile; wrap only when space is insufficient
- Mirror the same title+tag layout in the skeleton (flex-row flex-wrap items-center)
- No linter errors introduced

Affected files:
- web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx
2025-08-24 10:52:43 +08:00
Calcium-Ion
524f6d6af5 Merge pull request #1644 from nekohy/feats-custom-request-headers
feats:add custom headers override
2025-08-24 10:14:32 +08:00
Nekohy
53f7a7993e fix: log name 2025-08-24 01:32:19 +08:00
Nekohy
abcb353793 feats:add custom headers override 2025-08-24 01:02:23 +08:00
t0ng7u
d7c2a9f1b8 feat(model-pricing): enhance pricing vendor intro components with performance optimizations and UX improvements
## Major Changes

### Performance Optimizations
- Add React.memo to all components to prevent unnecessary re-renders
- Implement useCallback for expensive functions (renderSearchActions, renderHeaderCard, etc.)
- Extract createSkeletonRect function outside component to avoid recreation
- Optimize constant definitions and reduce magic numbers

### UI/UX Enhancements
- Replace Popover with Modal for vendor description display
- Add modal max height and vertical scrolling support
- Fix filter modal not showing on first click by always mounting component
- Improve responsive design with mobile-specific modal sizing

### Code Structure Improvements
- Refactor avatar rendering logic into pure helper functions
- Reorganize constants into semantic groups (CONFIG, THEME_COLORS, COMPONENT_STYLES, CONTENT_TEXTS)
- Simplify complex vendor info processing logic
- Fix sourceModels selection logic for better data handling

### Bug Fixes
- Fix React key prop missing in skeleton elements causing render errors
- Resolve modal mounting timing issues
- Correct dependency arrays in useCallback hooks

### Code Quality
- Remove redundant comments while preserving essential documentation
- Add displayName to all memo components for better debugging
- Standardize code formatting and naming conventions
- Improve TypeScript-like prop validation

## Files Modified
- PricingTopSection.jsx
- PricingVendorIntro.jsx
- PricingVendorIntroSkeleton.jsx
- PricingVendorIntroWithSkeleton.jsx
- SearchActions.jsx

## Performance Impact
- Reduced re-renders by approximately 60-80%
- Improved memory efficiency through function memoization
- Enhanced user experience with smoother interactions
2025-08-24 00:10:26 +08:00
t0ng7u
7969df3926 🤖 style: Make some changes 2025-08-23 22:03:34 +08:00
t0ng7u
97c52a6991 🐛 fix(model-pricing/header): pass sidebarProps to PricingFilterModal to prevent FilterModalContent crash
- Re-introduce and forward `sidebarProps` from PricingTopSection to PricingFilterModal
- Fix TypeError: “Cannot destructure property 'showWithRecharge' of 'sidebarProps' as it is undefined”
- Keep modal state managed at top section; no other behavioral changes
- Lint passes

Files touched:
- web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx
2025-08-23 21:56:43 +08:00
t0ng7u
a50288c186 🤓 style: add outline theme in pricingcard copy button 2025-08-23 21:48:18 +08:00
t0ng7u
f246c12959 🎨 refactor(model-pricing/header): unify header design, extract SearchActions, and improve skeleton
- Extract SearchActions.jsx and replace inline renderSearchActions in PricingVendorIntro.jsx for reuse
- Refactor PricingVendorIntro.jsx:
  - Introduce renderHeaderCard(), tagStyle, getCoverStyle(), and MAX_VISIBLE_AVATARS constant
  - Standardize vendor header cover (gradient + background image) and tag contrast
  - Use border instead of ring for vendor badges; unify visuals and remove Tailwind ring dependency
  - Rotate vendors every 2s only when filterVendor === 'all' and vendor count > 3
  - Remove unused imports; keep prop surface minimal; pass setShowFilterModal downward only
- Refactor PricingVendorIntroSkeleton.jsx:
  - Add getCoverStyle() and rect() helpers; rebuild skeleton to match final UI
  - Replace invalid Skeleton.Input usage; add missing keys; unify colors/borders/radius
- Update PricingTopSection.jsx:
  - Manage filter modal locally; drop redundant prop passing
- Update PricingVendorIntroWithSkeleton.jsx:
  - Align prop interface; forward only required props and keep useMinimumLoadingTime
- Add: web/src/components/table/model-pricing/layout/header/SearchActions.jsx
- Lint: all files pass; no dark:* classes present in this scope

Files touched:
- web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx
- web/src/components/table/model-pricing/layout/header/SearchActions.jsx (new)
2025-08-23 21:11:40 +08:00
t0ng7u
5d7ab194e2 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-23 19:30:07 +08:00
t0ng7u
8a329f6522 🎨 refactor(ui): use lucide-react for search/refresh and chevron icons
- DashboardHeader.jsx: replace Semi's IconSearch/IconRefresh with lucide-react's Search/RefreshCw (size 16), preserve existing button styles
- UptimePanel.jsx: replace Semi's IconRefresh with lucide-react's RefreshCw (size 14), keep styling intact
- UserArea.jsx: replace Semi's IconChevronDown with lucide-react's ChevronDown (size 14), preserve visual parity
- Update imports: remove @douyinfe/semi-icons usage where replaced; add lucide-react imports
- Verified no remaining IconSearch/IconRefresh in dashboard; no new linter errors

Motivation: unify icon library for core actions and improve UI consistency.
Follow-ups: consider migrating remaining Semi icons (e.g., plus/minus, charts) to lucide-react.
2025-08-23 19:29:56 +08:00
Calcium-Ion
4200edb983 Merge pull request #1161 from lollipopkit/main
feat: query usage of token
2025-08-23 15:58:25 +08:00
CaIon
93ce48aca8 feat: restructure token usage routes and enhance token retrieval logic 2025-08-23 15:45:43 +08:00
Calcium-Ion
df1ec4832c Merge branch 'alpha' into main 2025-08-23 15:27:08 +08:00
Calcium-Ion
e3a38d27f5 Merge pull request #1611 from nekohy/feats-zhipu_4v-anthropic
Feats:Standardize ClaudeHandler, add Zhipu_4v Anthropic native channel support
2025-08-23 13:48:18 +08:00
Calcium-Ion
754498a012 Merge pull request #1635 from feitianbubu/pr/fix-task-info-channel-type
Pr/fix task info channel type
2025-08-23 13:46:52 +08:00
Calcium-Ion
4226746675 Merge pull request #1642 from QuantumNous/fix-retry-and-rerank
fix: retry requeset body incorrect and fix rerank
2025-08-23 13:46:40 +08:00
CaIon
94536be9be fix: enhance error handling for invalid request types in relay handlers 2025-08-23 13:34:56 +08:00
CaIon
2c6a9245ee refactor: rename relay-text.go to compatible_handler.go for clarity 2025-08-23 13:13:57 +08:00
CaIon
fc18a3c89e fix: improve request handling by deep copying OpenAIResponsesRequest 2025-08-23 13:13:10 +08:00
CaIon
4f23e53002 feat: 修复重试后请求结构混乱,修复rerank端点无法使用 2025-08-23 13:12:15 +08:00
t0ng7u
005e9659e1 🐛 fix(header): prevent NotificationButton from shrinking when unread badge appears
- Remove `size='small'` when the button is wrapped by `Badge`
- Keep button dimensions consistent with/without badge
- Preserve 18px icon size and existing styles/accessibility
- Lint check passed with no issues

Files: web/src/components/layout/HeaderBar/NotificationButton.jsx
2025-08-23 03:40:32 +08:00
t0ng7u
43c6bbb3ad 📱 fix(ui/topup): make stats numbers responsive on mobile
Reduce KPI font size on small screens to prevent overlapping of large
numbers while preserving the desktop layout.

Changes:
- InvitationCard.jsx: use `text-base sm:text-2xl` for
  pending earnings, total earnings, and invite count.
- RechargeCard.jsx: use `text-base sm:text-2xl` for
  current balance, historical usage, and request count.

Impact:
- Visual-only; no behavioral changes.
- Desktop/tablet unchanged.
- Lint passes.

Files:
- web/src/components/topup/InvitationCard.jsx
- web/src/components/topup/RechargeCard.jsx
2025-08-23 03:36:18 +08:00
t0ng7u
def4d16c73 🎨 refactor(ui): harden iframe messaging
- useHeaderBar.js
  - Wrap handlers with useCallback (logout, language/theme toggle, mobile menu)
  - Add null checks and try/catch around iframe postMessage (theme & language)
  - Keep minimal effect deps; remove unused StatusContext dispatch
  - Logo preload effect safe and scoped to `logo`

- index.jsx
  - No functional changes; locale memoization remains stable

Chore:
- Lint clean; no runtime warnings
- Verified no render loops or performance regressions
2025-08-23 03:17:03 +08:00
t0ng7u
61ae19ac82 🌓 feat(ui): add auto theme mode, refactor ThemeToggle, optimize header theme handling
- Feature: Introduce 'auto' theme mode
  - Detect system preference via matchMedia('(prefers-color-scheme: dark)')
  - Add useActualTheme context to expose the effective theme ('light'|'dark')
  - Persist selected mode in localStorage ('theme-mode') with 'auto' as default
  - Apply/remove `dark` class on <html> and sync `theme-mode` on <body>
  - Broadcast effective theme to iframes

- UI: Redesign ThemeToggle with Dropdown items and custom highlight
  - Replace non-existent IconMonitor with IconRefresh
  - Use Dropdown.Menu + Dropdown.Item with built-in icon prop
  - Selected state uses custom background highlight; hover state preserved
  - Remove checkmark; selection relies on background styling
  - Current button icon reflects selected mode

- Performance: reduce re-renders and unnecessary effects
  - Memoize theme options and current button icon (useMemo)
  - Simplify handleThemeToggle to accept only explicit modes ('light'|'dark'|'auto')
  - Minimize useEffect dependencies; remove unrelated deps

- Header: streamline useHeaderBar
  - Use useActualTheme for iframe theme messaging
  - Remove unused statusDispatch
  - Remove isNewYear from theme effect dependencies

- Home: send effective theme (useActualTheme) to external content iframes

- i18n: add/enhance theme-related copy in locales (en/zh)

- Chore: minor code cleanup and consistency
  - Improve readability and maintainability
  - Lint clean; no functional regressions
2025-08-23 03:02:35 +08:00
t0ng7u
08add538a0 💄 style(ui): enhance table scroll card scrollbar visibility and appearance
- Make Y-axis scrollbar visible for .table-scroll-card .semi-card-body
- Reduce scrollbar width from 6px to 4px for a more subtle appearance
- Decrease scrollbar opacity from 0.3 to 0.2 for lighter color
- Adjust hover opacity from 0.5 to 0.35 for consistent lighter theme
- Remove previous scrollbar hiding styles to improve user experience

This change improves the usability of table scroll cards by providing
visual feedback for scrollable content while maintaining a clean,
unobtrusive design aesthetic.
2025-08-23 02:14:17 +08:00
t0ng7u
bd166b2f77 🚀 perf(vite): remove visactor manual chunk to avoid preloading on Home
Home was unexpectedly loading the `visactor-*.js` bundle on first paint. This
happened because the Vite manualChunks entry created a standalone vendor
chunk for VisActor, which Vite then preloaded on the initial route.

What’s changed
- Removed `visactor` from `build.rollupOptions.output.manualChunks` in `web/vite.config.js`.

Why
- Prevents VisActor from being preloaded on the Home page.
- Restores the intended behavior: VisActor loads only when the Dashboard (data
  analytics) is visited.

Impact
- Smaller initial payload and fewer network requests on Home.
- No functional changes to charts; loading behavior is now route-driven.

Test plan
- Build the app: `cd web && npm run build`.
- Open the preview and visit `/`: ensure no `visactor-*.js` is requested.
- Navigate to `/console` (Dashboard): ensure `visactor-*.js` loads as expected.
2025-08-23 01:54:32 +08:00
t0ng7u
8b7384e47f ♻️ refactor(home): build serverAddress using ${window.location.origin}
Replace the fallback assignment of serverAddress in `web/src/pages/Home/index.jsx`
to use a template literal for `window.location.origin`.

- Aligns with the preferred style for constructing base URLs
- Keeps formatting consistent across the app
- No functional changes; behavior remains the same
- Lint passes with no new warnings or errors

Files affected:
- web/src/pages/Home/index.jsx
2025-08-23 01:42:32 +08:00
t0ng7u
60dc032cb8 refactor: unify layout, adopt Semi UI Forms, dynamic presets, and fix duplicate requests
- Unify TopUp into a single-page layout and remove tabs
- Replace custom inputs with Semi UI Form components (Form.Input, Form.InputNumber, Form.Slot)
- Move online recharge form into the stats Card content for tighter, consistent layout
- Add account stats Card with blue theme (consistent with InvitationCard style)
- Remove RightStatsCard and inline the stats UI directly in RechargeCard
- Change preset amount UI to horizontal quick-action Buttons; swap order with payment methods
- Replace payment method Cards with Semi UI Buttons
  - Use Button icon prop for Alipay/WeChat/Stripe with brand colors
  - Use built-in Button loading; remove custom “processing...” text
- Replace custom spinners with Semi UI Spin and keep Skeleton for amount loading
- Wrap Redeem Code in a Card; use Typography for “Looking for a code? Buy Redeem Code” link
- Show info Banner when online recharge is disabled (instead of warning)

TopUp data flow and logic
- Generate preset amounts from min_topup using multipliers [1,5,10,30,50,100,300,500]
- Deduplicate /api/user/aff using a ref guard; fetch only once on mount
- Simplify user self fetch: update context only; remove unused local states and helpers
- Normalize payment method keys to alipay/wxpay/stripe and assign default colors

Cleanup
- Delete web/src/components/topup/RightStatsCard.jsx
- Remove unused helpers and local states in index.jsx (userQuota, userDataLoading, getUsername)

Dev notes
- No API changes; UI/UX refactor only
- Lint clean (no new linter errors)

Files
- web/src/components/topup/RechargeCard.jsx
- web/src/components/topup/index.jsx
- web/src/components/topup/InvitationCard.jsx (visual parity reference)
- web/src/components/topup/RightStatsCard.jsx (removed)
2025-08-23 01:32:54 +08:00
t0ng7u
d47190f1fd 🧹 refactor(settings): remove models API usage from Personal Settings
Personal Settings no longer needs to fetch `/api/user/models` since models are now displayed directly. This change removes the unused data flow to simplify the component and avoid unnecessary requests.

Changes:
- Removed `models` and `modelsLoading` state from `web/src/components/settings/PersonalSetting.jsx`
- Removed `loadModels` function and its invocation in the initial effect
- Kept UI behavior unchanged; no functional differences on the Personal Settings page

Notes:
- Lint passes with no new issues
- Other parts of the app still using `/api/user/models` (e.g., Tokens pages) are intentionally left intact

Rationale:
- Models are already displayed; the API call in Personal Settings became redundant
2025-08-22 23:01:32 +08:00
CaIon
e581422810 fix: update response body handling in OpenAI relay format 2025-08-22 17:33:20 +08:00
Calcium-Ion
ad151bb919 Merge pull request #1606 from funnycups/patch-1
fix: prompt calculation
2025-08-22 17:30:53 +08:00
feitianbubu
b5040e0182 fix: channel type nil point 2025-08-22 15:55:25 +08:00
Calcium-Ion
c826d06d2c Merge pull request #1627 from QuantumNous/feature/new-partner-intro
🤝 docs(README): Introduction to New Partners
2025-08-21 12:58:53 +08:00
t0ng7u
7c058bfee3 🤝 docs(README): Enhancing Partner Layout 2025-08-21 12:51:49 +08:00
t0ng7u
3133e91d8e 🤝 docs(README): Enhancing Partner Layout 2025-08-21 12:49:56 +08:00
t0ng7u
b5e55c81d4 🤝 docs(README): Introduction to New Partners 2025-08-21 12:42:19 +08:00
t0ng7u
0837747428 🍭 style(ui): update the README.md style 2025-08-19 01:46:09 +08:00
t0ng7u
518763cd08 🍭 style(ui): update the README.md style 2025-08-19 01:44:44 +08:00
t0ng7u
2b862f65a2 🔧 fix: adjust IO.NET logo viewBox for proper display size
- Fix io-net.svg viewBox from "0 0 1000 1000" to "100 440 800 120"
- Resolve issue where IO.NET logo appeared too small on GitHub
- Crop viewBox to actual logo content area for better visibility
- Ensure consistent display size with other partner logos
- Change aspect ratio from 1:1 to 6.67:1 to match horizontal layout
2025-08-19 01:02:57 +08:00
t0ng7u
cb53adef62 🍭 style(ui): update the README.md style 2025-08-19 00:52:11 +08:00
t0ng7u
c3481f5a67 🤝 docs: add IO.NET as trusted partner
- Add IO.NET to trusted partners section in README.md
- Add IO.NET to trusted partners section in README.en.md
- Use io-net.png logo and https://io.net/ as website link
- Adjust layout to display 3 partners in the second row for better balance
- IO.NET provides decentralized GPU computing services for AI workloads
2025-08-19 00:49:44 +08:00
t0ng7u
ba50b6fcc0 🍭 style(ui): update the README.md style 2025-08-19 00:43:36 +08:00
t0ng7u
003246f113 🤝 docs: add Alibaba Cloud as trusted partner
- Add Alibaba Cloud to trusted partners section in README.md
- Add Alibaba Cloud to trusted partners section in README.en.md
- Use aliyun.svg logo and https://www.aliyun.com/ as website link
- Maintain consistent formatting with existing partners
2025-08-19 00:39:58 +08:00
t0ng7u
13aee98d4a Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-19 00:25:41 +08:00
t0ng7u
6c94573323 ♻️ refactor(InvitationCard): restructure cards with title prop and clean up styling
- Move card titles (earnings, total revenue, invitations) to Card title prop for consistency
- Remove custom color classes in favor of Semi-UI's built-in type system
- Standardize Text component usage with strong and tertiary types
- Improve code maintainability and visual consistency across all cards
- Align structure with existing reward description card pattern
2025-08-19 00:22:28 +08:00
creamlike1024
03a257bddb Merge branch 'Sh1n3zZ-alpha' into alpha 2025-08-18 23:35:28 +08:00
creamlike1024
e02e1e8d4a fix: Guard against negative or zero n from ExtraBody to prevent uint underflow 2025-08-18 23:35:01 +08:00
t0ng7u
57f1015197 🍎 style(ui): remove `transparent' style in model-pricing search input 2025-08-18 22:25:46 +08:00
Sh1n3zZ
974b93a8be feat: imagen for vertex channel 2025-08-18 21:49:55 +08:00
Nekohy
652d71d799 feats:Standardize ClaudeHandler, add zhipu_4v Anthropic native support 2025-08-18 13:14:48 +08:00
CaIon
f6d4c586eb fix: add nil check for Writer in FlushWriter function 2025-08-18 12:48:56 +08:00
t0ng7u
adc7fbd424 ♻️ refactor(web): migrate React modules from .js to .jsx and align entrypoint
- Rename React components/pages/utilities that contain JSX to `.jsx` across `web/src`
- Update import paths and re-exports to match new `.jsx` extensions
- Fix Vite entry by switching `web/index.html` from `/src/index.js` to `/src/index.jsx`
- Verified remaining `.js` files are plain JS (hooks/helpers/constants) and do not require JSX
- No runtime behavior changes; extension and reference alignment only

Context: Resolves the Vite pre-transform error caused by the stale `/src/index.js` entry after migrating to `.jsx`.
2025-08-18 04:14:35 +08:00
t0ng7u
cfc6bc8e5e feat(ui): add hover scale animation to header logo
Add smooth scale-up animation effect when hovering over the header logo to enhance user interaction experience.

Changes:
- Add `group` class to Link element to enable Tailwind group hover functionality
- Update transition from `transition-opacity` to `transition-all` for smooth scaling
- Increase hover scale from `scale-105` to `scale-110` (10% enlargement)
- Maintain 200ms transition duration for optimal user experience

The logo now smoothly scales to 110% size on hover and returns to original size when mouse leaves, providing better visual feedback for user interactions.
2025-08-18 03:43:34 +08:00
t0ng7u
da802ece3b 🤔style(ui): remove large size in auth components 2025-08-18 03:39:17 +08:00
t0ng7u
1074f8acb1 🎛️ refactor: HeaderBar into modular components, add shared skeletons, and primary-colored nav hover
Summary
- Split HeaderBar into maintainable components and hooks
- Centralized skeleton loading UI via a reusable SkeletonWrapper
- Improved navigation UX with primary-colored hover indication
- Preserved API surface and passed linters

Why
- Improve readability, reusability, and testability of the header
- Remove duplicated skeleton logic across files
- Provide clearer hover feedback consistent with the theme

What’s changed
- Components (web/src/components/layout/HeaderBar/)
  - New container: index.js
  - New UI components: HeaderLogo.js, Navigation.js, ActionButtons.js, UserArea.js, MobileMenuButton.js, NewYearButton.js, NotificationButton.js, ThemeToggle.js, LanguageSelector.js
  - New shared skeleton: SkeletonWrapper.js
  - Updated entry: HeaderBar.js now re-exports ./HeaderBar/index.js
- Hooks (web/src/hooks/common/)
  - New: useHeaderBar.js (state and actions for header)
  - New: useNotifications.js (announcements state, unread calc, open/close)
  - New: useNavigation.js (main nav link config)
- Skeleton refactor
  - Navigation.js: replaced inline skeletons with <SkeletonWrapper type="navigation" .../>
  - UserArea.js: replaced inline skeletons with <SkeletonWrapper type="userArea" .../>
  - HeaderLogo.js: replaced image/title skeletons with <SkeletonWrapper type="image"/>, <SkeletonWrapper type="title"/>
- Navigation hover UX
  - Added primary-colored hover to nav items for clearer pointer feedback
  - Final hover style: hover:text-semi-color-primary (kept rounded + transition classes)

Non-functional
- No breaking API changes; HeaderBar usage stays the same
- All modified files pass lint checks

Notes for future work
- SkeletonWrapper is extensible: add new cases (e.g., card) in one place
- Components are small and test-friendly; unit tests can be added per component

Affected files (key)
- web/src/components/layout/HeaderBar.js
- web/src/components/layout/HeaderBar/index.js
- web/src/components/layout/HeaderBar/Navigation.js
- web/src/components/layout/HeaderBar/UserArea.js
- web/src/components/layout/HeaderBar/HeaderLogo.js
- web/src/components/layout/HeaderBar/ActionButtons.js
- web/src/components/layout/HeaderBar/MobileMenuButton.js
- web/src/components/layout/HeaderBar/NewYearButton.js
- web/src/components/layout/HeaderBar/NotificationButton.js
- web/src/components/layout/HeaderBar/ThemeToggle.js
- web/src/components/layout/HeaderBar/LanguageSelector.js
- web/src/components/layout/HeaderBar/SkeletonWrapper.js
- web/src/hooks/common/useHeaderBar.js
- web/src/hooks/common/useNotifications.js
- web/src/hooks/common/useNavigation.js
2025-08-18 03:20:56 +08:00
t0ng7u
a0e6a72b69 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-18 02:45:04 +08:00
t0ng7u
795cfd471a 🎨 refactor: UserInfoHeader layout and styling
- Restructure avatar-name-tags layout to left-right alignment
  - Avatar positioned on the left
  - Name aligned to avatar top, tags aligned to avatar bottom
  - Remove margin-top usage in favor of flexbox justify-between
- Simplify desktop statistics cards to single-line layout
  - Format as "icon + label: value" without stacked layout
  - Remove custom color classes for cleaner styling
- Update UI component styling
  - Increase tag size from small to large
  - Reduce cover height from responsive to fixed 32
  - Add Badge import for future enhancements
  - Clean up icon and text color classes
- Maintain responsive behavior and accessibility
2025-08-18 02:44:53 +08:00
CaIon
0a053ee633 feat: 完善gemini格式转换 2025-08-17 19:08:06 +08:00
CaIon
85f81df2f8 fix: remove redundant reasoning assignment in ChatCompletionsStreamResponseChoiceDelta 2025-08-17 18:43:31 +08:00
t0ng7u
94d9607447 ♻️ refactor: HeaderBar into modular, maintainable components & polish responsive UI
Summary
• Extracted `LogoSection`, `NavLinks`, `UserArea`, and `ActionButtons` from `HeaderBar.js`, reducing complexity and improving readability.
• Removed unused state, handlers, and redundant imports from `HeaderBar.js`.
• Simplified mobile/desktop logic:
  – Menu icon now shows only on mobile `/console` routes.
  – Logo, system name, and mode tags appear on all desktop screens and on mobile non-console pages.
• Reworked skeleton loaders:
  – Narrower width on mobile (`40 px`) and clearer spacing (`p-1`).
• Added global `.scrollbar-hide` utility in `index.css` to enable scrollable areas without visible scrollbars.
• Ensured nav bar is horizontally scrollable across all breakpoints.
• Cleaned up language-switch, New Year, and notice handlers; consolidated side effects.
• Updated imports and internal calls after component extraction.
• Passed required props to new sub-components and removed obsolete ones.
• Confirmed zero linter warnings after refactor.

Why
Breaking the monolithic header into focused components makes future updates simpler, facilitates isolation testing, and aligns with the existing component architecture under `components/`. The UI tweaks provide a better mobile experience and consistent styling across devices.

Notes
No backend changes required. All routes and contexts remain untouched.
2025-08-17 17:50:01 +08:00
t0ng7u
2be4489d18 feat: unify skeleton loading behavior for Midjourney logs actions
• Introduced `useMinimumLoadingTime` to `MjLogsActions.jsx`, guaranteeing a minimum skeleton display duration for smoother UX
• Refactored loading state UI to use wrapped `Skeleton` with placeholder, matching the implementation in `UsageLogsActions.jsx`
• Kept existing banner, admin checks, and compact-mode toggle intact while streamlining the code
• Ensures consistent loading indicators across usage- and MJ-log tables
2025-08-17 16:53:48 +08:00
t0ng7u
d34e4f1f28 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-17 16:45:30 +08:00
t0ng7u
11a81c25ef 🎨 refactor: Setup Wizard UI & Clean Up Redundant Code
Summary of changes
1. SetupWizard.jsx
   • Center card (`min-h-screen flex items-center justify-center`) and remove top margin.
   • Merge step indicator/content into single card; added `Divider` separator.
   • Added sweep-shine animation to current step title via existing `shine-text` class.
   • Simplified imports (removed Avatar / Typography) and deleted unused modal state.

2. Step components
   • Stripped outer `Card` and header sections from `DatabaseStep.jsx`, `AdminStep.jsx`, `UsageModeStep.jsx`, `CompleteStep.jsx` to fit single-card layout.
   • Removed unused imports and props.

3. Components cleanup
   • Deleted obsolete files:
     - `components/setup/components/SetupSteps.jsx`
     - `components/setup/components/modals/UsageModeInfoModal.jsx`
   • Updated `setup/index.jsx` exports accordingly.

4. Styling
   • Ensured global sweep-shine effect already present in `index.css` is reused for step titles.

5. i18n
   • Pruned unused translation keys related to removed components from `i18n/locales/en.json`.

6. Miscellaneous
   • Removed redundant Avatar/Icon imports from multiple files.
   • All linter checks pass with no new warnings.

This commit consolidates the initialization flow into a cleaner, centered single-card wizard, adds visual polish, and reduces dead code for easier maintenance.
2025-08-17 16:45:11 +08:00
CaIon
c18414cbe4 refactor: extract FlushWriter function for improved stream flushing 2025-08-17 15:30:31 +08:00
CaIon
998305fd00 fix: improve error handling for image edit form request parsing 2025-08-17 15:18:57 +08:00
CaIon
49ab1a3b38 fix: remove unnecessary option from error handling in image request conversion 2025-08-17 15:13:17 +08:00
t0ng7u
c123ea3179 style(Account UX): resilient binding layout, copyable popovers, pastel header, and custom pay colors
- AccountManagement.js
  - Prevent action button from shifting when account IDs are long by adding gap, min-w-0, and flex-shrink-0; keep buttons in a fixed position.
  - Add copyable Popover for account identifiers (email/GitHub/OIDC/Telegram/LinuxDO) using Typography.Paragraph with copyable; reveal full text on hover.
  - Ensure ellipsis works by rendering the popover trigger as `block max-w-full truncate`.
  - Import Popover and wire up `renderAccountInfo` across all binding rows.

- UserInfoHeader.js
  - Apply unified `with-pastel-balls` background to match PricingVendorIntro.
  - Remove legacy absolute-positioned circles and top gradient bar to avoid visual overlap.

- RechargeCard.jsx
  - Colorize non-Alipay/WeChat/Stripe payment icons using backend `pay_methods[].color`; fallback to `var(--semi-color-text-2)`.
  - Add `showClear` to the redemption code input for quicker clearing.

Notes:
- No linter errors introduced.
- i18n strings and behavior remain unchanged except for improved UX and visual consistency.
2025-08-17 11:45:55 +08:00
t0ng7u
a6ad49dba0 Revert "️ perf: Defer Visactor chart libs to dashboard; minimize home bundle"
This reverts commit b67a42e0a8.
2025-08-17 10:49:09 +08:00
t0ng7u
3749be3e09 💳 feat(TopUp): unify payment cards, add header stats, brand icons, and mobile refinements [[memory:5659506]]
- Add RightStatsCard and place it in RechargeCard header
  - Shows current balance, historical spend, and request count
  - Mobile: stacks under title; three metrics split equally (flex-1); vertical dividers hidden on small screens
  - Remove extra margins; small card styling

- RechargeCard
  - Replace redeem code Input icon with Semi UI IconGift
  - Style “Payable amount” number in red and bold; keep same style in confirm modal
  - Always render payment methods as Cards (remove Button variant) with adaptive grid
  - Use brand color icons: SiAlipay (#1677FF), SiWechat (#07C160), SiStripe (#635BFF)
  - Replace Stripe icon with SiStripe
  - Integrate RightStatsCard props; adjust header to flex-col on mobile and flex-row on desktop
  - Hide Banner close button when online top-up is disabled (closeIcon={null})

- InvitationCard
  - Simplify to match RechargeCard’s minimalist slate style
  - Use Card title for “Rewards” and place content directly in body
  - Improve link input and copy button; use Badge dots for bullet points

- TopUp index
  - Remove separate right-column stats card; pass userState and renderQuota to RechargeCard

- Cleanup
  - Lint passes; no functional changes to APIs or business logic
2025-08-17 04:00:58 +08:00
t0ng7u
b67a42e0a8 ️ perf: Defer Visactor chart libs to dashboard; minimize home bundle
Home started loading `/assets/visactor-*.js` due to static imports of `@visactor/react-vchart` and the Semi theme in dashboard components/hooks. This change moves chart dependencies to lazy/dynamic imports so they load only on dashboard routes.

Changes
- StatsCards.jsx: replace static `VChart` import with `React.lazy` + `Suspense` (fallback: null)
- ChartsPanel.jsx: replace static `VChart` import with `React.lazy` + `Suspense` (fallback: null)
- useDashboardCharts.js: remove static `initVChartSemiTheme` import; dynamically import and initialize the theme inside `useEffect` with a cancel guard

Behavior
- Home page no longer downloads `visactor` chunks on first load
- Chart libraries are fetched only when visiting `/console` (dashboard)
- No functional changes to chart rendering

Files
- web/src/components/dashboard/StatsCards.jsx
- web/src/components/dashboard/ChartsPanel.jsx
- web/src/hooks/dashboard/useDashboardCharts.js

Verification
- Build the app (`npm run build`) and open `/`: no `/assets/visactor-*.js` requests
- Navigate to `/console`: `visactor` chunks load and charts render as expected

Breaking Changes
- None

Follow-ups
- If needed, further trim homepage bundle by reducing heavy icon sets on the hero section
2025-08-17 01:22:44 +08:00
t0ng7u
9805d35a5d ♻️ refactor(personal-settings): Break down PersonalSetting.js into modular components
- Split the 1554-line PersonalSetting.js file into smaller, maintainable components
- Created organized folder structure under personal/:
  - components/: UserInfoHeader for shared user info display
  - tabs/: ModelsList, AccountBinding, SecuritySettings, NotificationSettings
  - modals/: EmailBindModal, WeChatBindModal, AccountDeleteModal, ChangePasswordModal
- Refactored main PersonalSetting component to use composition pattern
- Improved code maintainability and separation of concerns
- Added collapsible prop to ModelsList tabs for better UX
- Fixed import path for TwoFASetting component in SecuritySettings
- Preserved all existing functionality and user interactions

This refactoring reduces the main file from 1554 to 484 lines and makes
the codebase more modular, testable, and easier to maintain.
2025-08-17 00:49:54 +08:00
funnycups
e3473e3c39 fix: prompt calculation
User will correctly get estimated prompt usage when upstream returns either zero or nothing.
2025-08-16 22:54:00 +08:00
t0ng7u
a1cab158ea Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-16 19:22:24 +08:00
t0ng7u
9934cdc5bd 🌐 feat(i18n): add internationalization support for TwoFASetting component
- Add comprehensive i18n support to TwoFASetting.js component
- Add all required English translations to en.json for 2FA settings
- Update component to accept t function as prop and use translation keys
- Fix prop passing in PersonalSetting.js to provide t function
- Maintain all existing UI improvements and functionality
- Support both Chinese and English interfaces for:
  * Main 2FA settings card with status indicators
  * Setup modal with guided steps (QR scan, backup codes, verification)
  * Disable 2FA modal with impact warnings and confirmation
  * Regenerate backup codes modal with success states
  * All buttons, placeholders, messages, and notifications
- Follow project i18n conventions using t('key') pattern
- Ensure seamless language switching for enhanced user experience

This enables the 2FA settings to be fully localized while preserving
the modern UI design and improved user workflow from previous updates.
2025-08-16 19:22:14 +08:00
CaIon
c834694992 fix: update token usage calculation 2025-08-16 19:11:15 +08:00
t0ng7u
aa1f5c6e4e Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-16 18:57:55 +08:00
t0ng7u
2d28fb3a73 💄 refactor(settings): redesign 2FA settings UI with unified Semi UI components
- Replace lucide-react icons with Semi UI icons for consistency
- Implement Steps component for guided 2FA setup modal flow
- Redesign disable and regenerate backup codes modals to match setup modal style
- Extract duplicate backup codes display logic into reusable BackupCodesDisplay component
- Move modal navigation buttons to proper footer parameter following Semi UI standards
- Replace custom styled dots with Badge components (warning/danger/success types)
- Use Banner and Divider components for better visual hierarchy
- Remove redundant modal step titles and download functionality
- Apply consistent rounded corners, spacing, and color scheme across all modals
- Improve responsive design with maxWidth constraints

This unifies the 2FA settings visual design with other settings pages and
enhances user experience through better component usage and layout structure.
2025-08-16 18:57:46 +08:00
Calcium-Ion
206ed55db4 Merge pull request #1605 from nekohy/feats-the-flexable-params-override
Feats: use the types of gjson,the error expection,the invert and the key missing process  of the condition
2025-08-16 18:27:22 +08:00
CaIon
9b0913343c feat: add support for Midjourney relay mode based on path prefix 2025-08-16 18:26:26 +08:00
Nekohy
5696a62c27 feats:the error of gjson.True and gjson.False 2025-08-16 17:16:46 +08:00
Nekohy
11a7ac9b10 feats:custom the key missing condition 2025-08-16 16:37:09 +08:00
Nekohy
cbce487362 feats:use the types of gjson,the error expection,the invert of the condition 2025-08-16 16:31:17 +08:00
CaIon
f8ca8d7cea fix: refactor processChannelError to use goroutine for asynchronous handling 2025-08-16 15:15:19 +08:00
CaIon
732e5d2661 fix: correct argument passing in DoDownloadRequest for MIME type retrieval 2025-08-16 15:03:42 +08:00
CaIon
5d6fac69c4 feat: implement file type detection from URL with enhanced MIME type handling 2025-08-16 14:56:29 +08:00
CaIon
5654d08086 feat: set API version for Azure and Vertex AI channel types 2025-08-16 14:56:19 +08:00
Calcium-Ion
73a7b33864 Merge pull request #1603 from nekohy/feats-the-flexable-params-override
feats: the flexable params override and compatible format
2025-08-16 12:32:15 +08:00
CaIon
64a752a3b4 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-16 12:28:46 +08:00
Calcium-Ion
0ad918c21d Merge pull request #1584 from feitianbubu/pr/use-proxy-fetch-models
feat: use proxy HTTP client fetch models
2025-08-16 12:27:36 +08:00
Calcium-Ion
5829bc69ca Merge pull request #1597 from wzxjohn/feature/add_openai_models
feat(relay): add OpenAI gpt-4.1 o3 o4 gpt-image-1 models
2025-08-16 12:27:20 +08:00
Nekohy
b591b4ebdf fix:moveValue bug 2025-08-16 11:58:40 +08:00
Nekohy
dd497d5bd8 feats: the flexable params override and compatible format 2025-08-16 11:27:47 +08:00
CaIon
f70cac54d1 feat: implement concurrent top-up locking mechanism to prevent race conditions 2025-08-15 20:03:38 +08:00
CaIon
f6a48434c1 feat: initialize channel metadata in mjproxy and relay processing 2025-08-15 19:14:29 +08:00
CaIon
c63b6b3ef8 feat: add checks for non-empty URLs in file metadata processing 2025-08-15 19:10:40 +08:00
CaIon
28bd31a30b feat: initialize channel metadata in relay processing 2025-08-15 19:00:16 +08:00
CaIon
491013e27a refactor: comment out SetContextKey to prevent token count meta setting 2025-08-15 18:43:08 +08:00
CaIon
0bb43aa464 refactor: update function signatures to include context and improve file handling #1599 2025-08-15 18:40:54 +08:00
wzxjohn
0edc707657 feat(ratio): add ratio for OpenAI models 2025-08-15 17:12:39 +08:00
wzxjohn
68b7badb80 feat(relay): add OpenAI gpt-4.1 o3 o4 gpt-image-1 models 2025-08-15 17:10:16 +08:00
CaIon
b57e97d2a1 feat: 优化ac自动机 2025-08-15 16:54:26 +08:00
CaIon
eeb421513b Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-15 16:47:36 +08:00
Calcium-Ion
ef1e380bbc Merge pull request #1577 from nekohy/feats-better-adaptor-for-openrouter
Fix reasoning adaptor for openrouter
2025-08-15 16:19:24 +08:00
CaIon
2579b3c0ba refactor: move anthropicKey retrieval to improve authorization handling 2025-08-15 16:08:55 +08:00
CaIon
d646a922ee refactor: set prompt tokens when not provided in usage 2025-08-15 15:55:01 +08:00
CaIon
8b2afcec90 fix panic 2025-08-15 15:15:21 +08:00
CaIon
726f1632b0 refactor: ensure graceful closure of response body in relay responses 2025-08-15 15:10:54 +08:00
Calcium-Ion
5a2dad2e16 Merge pull request #1590 from yyhhyyyyyy/fix/openrouter-custom-ratio-billing
fix: prevent OpenRouter cache calculation with custom model ratios
2025-08-15 15:02:57 +08:00
yyhhyyyyyy
039b00d695 Merge remote-tracking branch 'origin/alpha' into fix/openrouter-custom-ratio-billing 2025-08-15 14:58:42 +08:00
CaIon
1cb63063f7 refactor: replace json.Marshal and json.Unmarshal with common.Marshal and common.Unmarshal 2025-08-15 14:52:17 +08:00
同語
5671503c28 Merge pull request #1593 from fatcat-ww/main
手机客户端下拉菜单采用导航栏的样式
2025-08-15 14:30:18 +08:00
Calcium-Ion
50dafeaa0b Merge pull request #1594 from QuantumNous/refactor_relay
refactor: Introduce pre-consume quota and unify relay handlers
2025-08-15 14:16:43 +08:00
CaIon
1d4850e47a refactor: improve logging for channel operations with detailed context 2025-08-15 14:15:03 +08:00
CaIon
cc4f73dc7e refactor: enhance logging messages for user quota handling in pre-consume logic 2025-08-15 14:08:15 +08:00
CaIon
067be3727e refactor: simplify domain masking logic by removing URL check 2025-08-15 13:46:46 +08:00
CaIon
edeb4791c9 Merge branch 'alpha' into refactor_relay
# Conflicts:
#	dto/openai_image.go
2025-08-15 13:46:34 +08:00
CaIon
2f25e44e60 refactor: update token type handling and improve token counting logic 2025-08-15 13:28:03 +08:00
CaIon
5fe1ce89ec refactor: improve request type validation and enhance sensitive information masking 2025-08-15 13:20:36 +08:00
CaIon
03fc89da00 refactor: add email masking function and enhance RelayInfo logging
This commit introduces a new function, MaskEmail, to mask user email addresses in logs, preventing PII leakage. Additionally, the RelayInfo logging has been updated to utilize this new masking function, ensuring sensitive information is properly handled. The channel test logic has also been improved to dynamically determine the relay format based on the request path.
2025-08-15 12:50:27 +08:00
CaIon
44e9b02b3f refactor: enhance error handling and masking for model not found scenarios 2025-08-15 12:41:05 +08:00
CaIon
7f1f368065 refactor: improve channel base URL handling and enhance RelayInfo logging 2025-08-14 22:15:18 +08:00
CaIon
89caccd4e0 refactor: enhance quota handling and logging for pre-consume operations 2025-08-14 21:30:03 +08:00
CaIon
6748b006b7 refactor: centralize logging and update resource initialization
This commit refactors the logging mechanism across the application by replacing direct logger calls with a centralized logging approach using the `common` package. Key changes include:

- Replaced instances of `logger.SysLog` and `logger.FatalLog` with `common.SysLog` and `common.FatalLog` for consistent logging practices.
- Updated resource initialization error handling to utilize the new logging structure, enhancing maintainability and readability.
- Minor adjustments to improve code clarity and organization throughout various modules.

This change aims to streamline logging and improve the overall architecture of the codebase.
2025-08-14 21:10:04 +08:00
fatcat-ww
baf086d5b3 Add files via upload 2025-08-14 20:38:50 +08:00
CaIon
e2037ad756 refactor: Introduce pre-consume quota and unify relay handlers
This commit introduces a major architectural refactoring to improve quota management, centralize logging, and streamline the relay handling logic.

Key changes:
- **Pre-consume Quota:** Implements a new mechanism to check and reserve user quota *before* making the request to the upstream provider. This ensures more accurate quota deduction and prevents users from exceeding their limits due to concurrent requests.

- **Unified Relay Handlers:** Refactors the relay logic to use generic handlers (e.g., `ChatHandler`, `ImageHandler`) instead of provider-specific implementations. This significantly reduces code duplication and simplifies adding new channels.

- **Centralized Logger:** A new dedicated `logger` package is introduced, and all system logging calls are migrated to use it, moving this responsibility out of the `common` package.

- **Code Reorganization:** DTOs are generalized (e.g., `dalle.go` -> `openai_image.go`) and utility code is moved to more appropriate packages (e.g., `common/http.go` -> `service/http.go`) for better code structure.
2025-08-14 20:05:06 +08:00
yyhhyyyyyy
d75e198304 fix: prevent OpenRouter cache calculation with custommodel ratios 2025-08-14 17:10:36 +08:00
t0ng7u
223f0d0850 🐛 fix(tokens): correct main Chat button navigation to prevent 404
The primary "Chat" button on the tokens table navigated to a 404 page
because it passed incorrect arguments to onOpenLink (using a raw
localStorage value instead of the parsed chat value).

Changes:
- Build chatsArray with an explicit `value` for each item.
- Use the first item's `name` and `value` for the main button, matching
  the dropdown behavior.
- Preserve existing error handling when no chats are configured.

Impact:
- Main "Chat" button now opens the correct link, consistent with the
  dropdown action.
- No API/schema changes, no UI changes.

File:
- web/src/components/table/tokens/TokensColumnDefs.js

Verification:
- Manually verified primary button and dropdown both navigate correctly.
- Linter passes with no issues.
2025-08-13 18:31:00 +08:00
feitianbubu
01cd279f9f feat: use proxy HTTP client fetch models 2025-08-13 18:17:11 +08:00
wzxjohn
3b26810c17 fix(adaptor): missing first text delta while convert OpenAI to Claude 2025-08-13 09:57:06 +08:00
t0ng7u
196e2a0abb 🐛 fix: Always update searchValue during IME composition to enable Chinese input in model search
Summary:
• Removed early return in `handleChange` that blocked controlled value updates while an Input Method Editor (IME) was composing text.
• Ensures that Chinese (and other IME-based) characters appear immediately in the “Fuzzy Search Model Name” field.
• No change to downstream filtering logic—`searchValue` continues to drive model list filtering as before.

Files affected:
web/src/hooks/model-pricing/useModelPricingData.js
2025-08-12 23:37:30 +08:00
t0ng7u
63b9457b6c 🔍 fix(pricing): synchronize search term with sidebar filters & reset behavior
* Add `searchValue` to every dependency array in `usePricingFilterCounts`
  to ensure group/vendor/tag counts and disabled states update dynamically
  while performing fuzzy search.

* Refactor `PricingTopSection` search box into a controlled component:
  - Accept `searchValue` prop and bind it to `Input.value`
  - Extend memo dependencies to include `searchValue`
  This keeps the UI in sync with state changes triggered by `handleChange`.

* Guarantee that `resetPricingFilters` clears the search field by
  leveraging the new controlled input.

As a result, sidebar counters/disabled states now react to search input,
and the “Reset” button fully restores default filters without leaving the
search term visible.
2025-08-12 23:32:25 +08:00
t0ng7u
0082b87f61 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-12 23:11:55 +08:00
t0ng7u
936b1f8d09 🐛 fix: group-ratio display & deduplicate price logic in model-pricing views
Summary
• Ensure “Group Ratio” shows correctly when “All” groups are selected.
• Eliminate redundant price calculations in both card and table views.

Details
1. PricingCardView.jsx
   • Removed obsolete renderPriceInfo function.
   • Calculate priceData once per model and reuse for header price string and footer ratio block.
   • Display priceData.usedGroupRatio as the group ratio fallback.

2. PricingTableColumns.js
   • Introduced WeakMap-based cache (getPriceData) to compute priceData only once per row.
   • Updated ratioColumn & priceColumn to reuse cached priceData.
   • Now displays priceData.usedGroupRatio, preventing empty cells for “All” group.

Benefits
• Correct visual output for group ratio across all views.
• Reduced duplicate calculations, improving render performance.
• Removed dead code, keeping components clean and maintainable.
2025-08-12 23:10:29 +08:00
Nekohy
c1a545ac23 feats:the Openrouter Claude thinking 2025-08-12 22:39:32 +08:00
Nekohy
33925dd313 fix:Delete the excess ReasoningEffort from the Openrouter OpenAI thinking model. 2025-08-12 21:37:12 +08:00
CaIon
f5abbeb353 fix(dalle): update ImageRequest struct to use json.RawMessage for flexible field types 2025-08-12 21:12:00 +08:00
CaIon
c13683e982 fix(auth): refine authorization header setting for messages endpoint #1575 2025-08-12 20:42:44 +08:00
CaIon
17bab355e4 fix(env): update STREAMING_TIMEOUT default value to 300 seconds 2025-08-12 19:58:04 +08:00
CaIon
e77effaf8b fix(adaptor): optimize multipart form handling and resource management 2025-08-12 19:57:56 +08:00
IcedTangerine
2e39323782 Merge pull request #1553 from feitianbubu/pr/add-jimeng-officail-api
feat: add jimeng video official api
2025-08-12 16:32:49 +08:00
CaIon
8db5356caf fix(database): improve MySQL Chinese character support validation 2025-08-12 16:31:00 +08:00
CaIon
fa2edd9d3f fix(relay): remove unnecessary channel type check for BadRequest 2025-08-12 16:12:47 +08:00
Calcium-Ion
7997a04a68 Merge pull request #1556 from QuantumNous/fix-register-mail-verifycode-waiting-time
fix: 注册时发送邮件验证码没有等待时间
2025-08-12 14:20:09 +08:00
CaIon
2c30b4cf60 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-12 14:13:34 +08:00
Calcium-Ion
38c3349a6a Merge pull request #1569 from duyazhe/codex/cloudflare-responses
feat: add responses support for cloudflare
2025-08-12 14:13:14 +08:00
CaIon
41cb01bac9 feat(database): enhance MySQL support for Chinese characters
- Added a check for MySQL charset/collation to ensure compatibility with Chinese characters during database initialization.
- Updated SQLite busy timeout from 5000ms to 30000ms for improved performance.
- Removed commented-out PostgreSQL migration logic for clarity.
2025-08-12 14:12:11 +08:00
同語
c2ef4c8e54 Update web_api.md 2025-08-12 11:15:32 +08:00
同語
a7cd44e536 Merge pull request #1563 from QAbot-zh/docs-update
docs(web_api): 修复 Markdown 表格格式
2025-08-12 11:14:35 +08:00
t0ng7u
dc12ec6dfd 🚫 feat(web): add 403 Forbidden page and AdminRoute guard
- Add new Forbidden page at /forbidden (`web/src/pages/Forbidden/index.js`)
  - Use Semi-UI Empty with IllustrationNoAccess (250x250)
  - Update i18n description to: '您无权访问此页面,请联系管理员~'
  - Align visual style with existing 404 page
- Introduce `AdminRoute` in `web/src/helpers/auth.js`
  - Use `UserContext`/localStorage; redirect to `/forbidden` when `!user` or `user.role < 10`
- Protect console/admin routes with `AdminRoute` and register `/forbidden` in `web/src/App.js`
- Update `web/src/i18n/locales/en.json`
  - Add English translation for the new forbidden message
  - Remove legacy "没有权限" entry
- Lint passes; no runtime errors observed
2025-08-12 10:45:21 +08:00
t0ng7u
6eec8851eb Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-12 10:22:13 +08:00
t0ng7u
39c966efdd 🐛 fix: make ModelSelectModal panels collapsible and default to collapsed
- Switch Collapse from controlled (activeKey) to uncontrolled (defaultActiveKey) so user toggling works
- Add a stable key to reset Collapse state when tab/category changes
- Default all panels to collapsed via defaultActiveKey: []
- Preserve Panel itemKey for consistent behavior
- No linter errors introduced

Scope: web/src/components/table/channels/modals/ModelSelectModal.jsx
2025-08-12 10:22:00 +08:00
Seefs
981023154b Merge pull request #1570 from seefs001/fix/zhipu_v4_thinking
fix: zhipu_v4 thinking
2025-08-11 21:43:35 +08:00
nekohy
14a9a99e2d fix: zhipu_v4 thinking 2025-08-11 21:37:10 +08:00
CaIon
e74c6f5de7 feat: Simplify response handling by returning raw response body directly 2025-08-11 20:07:24 +08:00
CaIon
d3170310ff feat: Refactor Gemini tools handling to support JSON raw message format 2025-08-11 19:48:04 +08:00
t0ng7u
03cfc05afd 🍭 ui: change pricing page card view pt-4 to py-4 2025-08-11 19:11:58 +08:00
t0ng7u
fa686207ed 🍭 ui: change pricing page card view p-4 to px-4 2025-08-11 18:32:19 +08:00
t0ng7u
e863be7ec3 ui: Add CSS ellipsis + Tooltip for SelectableButtonGroup; keep Tag intact
- Truncate long labels via pure CSS and always show full text in a Tooltip
- Ensure the right-side Tag is never truncated and remains fully visible
- Simplify implementation: remove overflow detection and ResizeObserver
- Use minimal markup with sbg-button/sbg-inner/sbg-label to enable shrinking
- Add global rules to allow `.semi-button-content` to shrink and ellipsize

Files:
- web/src/components/common/ui/SelectableButtonGroup.jsx
- web/src/index.css

No API changes; visuals improved and code complexity reduced.
2025-08-11 18:27:32 +08:00
t0ng7u
2d4edb3eca 🤓 style(ui): remove the pricing page’s border style to reduce visual clutter 2025-08-11 17:44:12 +08:00
t0ng7u
ba5333a092 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-11 17:38:16 +08:00
CaIon
53fa7255ec feat: Refactor model handling to use UpstreamModelName for request processing 2025-08-11 17:32:58 +08:00
CaIon
dddf772f19 feat: Update request URL handling for Claude relay format in adaptor #1557 2025-08-11 17:17:56 +08:00
feitianbubu
3768fc37da feat: init default vendor 2025-08-11 16:47:12 +08:00
CaIon
9d6d580cbd Merge remote-tracking branch 'origin/alpha' into alpha
# Conflicts:
#	relay/channel/openai/adaptor.go
2025-08-11 16:35:23 +08:00
Q.A.zh
c3696cd857 docs(web_api): 修复 Markdown 表格格式 2025-08-11 08:34:14 +00:00
CaIon
c7498b768c feat: Update Azure responses API version handling in adaptor 2025-08-11 16:34:07 +08:00
t0ng7u
da17bdb688 💄 style: Use segmented renderer for billing types in Models table; keep Pricing view unchanged
Frontend
- Models table (model management):
  - Render billing types with the same segmented list component (renderLimitedItems) used by tags and endpoints
  - Display quota_types as an array with capped items (maxDisplay: 3) and graceful fallback for unknown types
- Pricing view (unchanged by request):
  - Revert to single-value quota_type rendering and sorter
  - Keep ratio display logic based on quota_type only

Files
- web/src/components/table/models/ModelsColumnDefs.js
- web/src/components/table/model-pricing/view/table/PricingTableColumns.js

Notes
- This commit only adjusts the model management UI rendering; pricing views remain as-is
2025-08-11 15:53:55 +08:00
duyazhe
c87a741fc9 feat: add responses support for cloudflare 2025-08-11 15:29:16 +08:00
t0ng7u
4ad8eefaec 🚀 perf: optimize model management APIs, unify pricing types as array, and remove redundancies
Backend
- Add GetBoundChannelsByModelsMap to batch-fetch bound channels via a single JOIN (Distinct), compatible with SQLite/MySQL/PostgreSQL
- Replace per-record enrichment with a single-pass enrichModels to avoid N+1 queries; compute unions for prefix/suffix/contains matches in memory
- Change Model.QuotaType to QuotaTypes []int and expose quota_types in responses
- Add GetModelQuotaTypes for cached O(1) lookups; exact models return a single-element array
- Sort quota_types for stable output order
- Remove unused code: GetModelByName, GetBoundChannels, GetBoundChannelsForModels, FindModelByNameWithRule, buildPrefixes, buildSuffixes
- Clean up redundant comments, keeping concise and readable code

Frontend
- Models table: switch to quota_types, render multiple billing modes ([0], [1], [0,1], future values supported)
- Pricing table: switch to quota_types; ratio display now checks quota_types.includes(0); array rendering for billing tags

Compatibility
- SQL uses standard JOIN/IN/Distinct; works across SQLite/MySQL/PostgreSQL
- Lint passes; no DB schema changes (quota_types is a JSON response field only)

Breaking Change
- API field renamed: quota_type -> quota_types (array). Update clients accordingly.
2025-08-11 14:40:01 +08:00
t0ng7u
e64b13c925 🔧 chore(db): drop legacy single-column UNIQUE indexes to prevent duplicate-key errors after soft-delete
Why
Previous versions created single-column UNIQUE constraints (`models.model_name`, `vendors.name`).
After introducing composite indexes on `(model_name, deleted_at)` and `(name, deleted_at)` for soft-delete support, those legacy constraints could still exist in user databases.
When a record was soft-deleted and re-inserted with the same name, MySQL raised `Error 1062 … for key 'models.model_name'`.

What
• In `migrateDB` and `migrateDBFast` paths of `model/main.go`, proactively drop:
  – `models.uk_model_name` and fallback `models.model_name`
  – `vendors.uk_vendor_name` and fallback `vendors.name`
• Keeps existing helper `dropIndexIfExists` to ensure the operation is MySQL-only and error-free when indexes are already absent.

Result
Startup migration now removes every possible legacy UNIQUE index, ensuring composite index strategy works correctly.
Users can soft-delete and recreate models/vendors with identical names without hitting duplicate-entry errors.
2025-08-11 01:25:13 +08:00
t0ng7u
b97a683bfd Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-10 23:18:16 +08:00
creamlike1024
6ea19b0ae2 feat(middleware): redis atomic incr, show waiting time 2025-08-10 23:18:09 +08:00
t0ng7u
dd9d2a150d 🤓 chore: format code file 2025-08-10 23:17:04 +08:00
t0ng7u
195be56c46 🏎️ perf: optimize aggregated model look-ups by batching bound-channel queries
Summary
-------
1. **Backend**
   • `model/model_meta.go`
     – Add `GetBoundChannelsForModels([]string)` to retrieve channels for multiple models in a single SQL (`IN (?)`) and deduplicate with `GROUP BY`.

   • `controller/model_meta.go`
     – In non-exact `fillModelExtra`:
       – Remove per-model `GetBoundChannels` calls.
       – Collect matched model names, then call `GetBoundChannelsForModels` once and merge results into `channelSet`.
       – Minor cleanup on loop logic; channel aggregation now happens after quota/group/endpoint processing.

Impact
------
• Eliminates N+1 query pattern for prefix/suffix/contains rules.
• Reduces DB round-trips from *N + 1* to **1**, markedly speeding up the model-management list load.
• Keeps existing `GetBoundChannels` API intact for single-model scenarios; no breaking changes.
2025-08-10 23:11:35 +08:00
Seefs
78662e8194 Merge pull request #1547 from seefs001/feature/model_list
 feat: Enhance model listing and retrieval with support for Anthropic and Gemini models; refactor routes for better API key handling
2025-08-10 22:57:20 +08:00
t0ng7u
c8f7aa76e7 🔍 feat: Show matched model names & counts for non-exact model rules
Summary
-------
1. **Backend**
   • `model/model_meta.go`
     – Add `MatchedModels []string` and `MatchedCount int` (ignored by GORM) to expose matching details in API responses.
   • `controller/model_meta.go`
     – When processing prefix/suffix/contains rules in `fillModelExtra`, collect every matched model name, fill `MatchedModels`, and calculate `MatchedCount`.

2. **Frontend**
   • `web/src/components/table/models/ModelsColumnDefs.js`
     – Import `Tooltip`.
     – Enhance `renderNameRule` to:
       – Display tag text like “前缀 5个模型” for non-exact rules.
       – Show a tooltip listing all matched model names on hover.

Impact
------
Users now see the total number of concrete models aggregated under each prefix/suffix/contains rule and can inspect the exact list via tooltip, improving transparency in model management.
2025-08-10 21:32:18 +08:00
creamlike1024
543e7b0b6b feat(middleware): add email verification rate limit 2025-08-10 21:22:53 +08:00
t0ng7u
42d2394585 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-10 21:14:47 +08:00
t0ng7u
94bd44d0f2 feat: enhance model billing aggregation & UI display for unknown quota type
Summary
-------
1. **Backend**
   • `controller/model_meta.go`
     – For prefix/suffix/contains rules, aggregate endpoints, bound channels, enable groups, and quota types across all matched models.
     – When mixed billing types are detected, return `quota_type = -1` (unknown) instead of defaulting to volume-based.

2. **Frontend**
   • `web/src/helpers/utils.js`
     – `calculateModelPrice` now handles `quota_type = -1`, returning placeholder `'-'`.

   • `web/src/components/table/model-pricing/view/card/PricingCardView.jsx`
     – Billing tag logic updated: displays “按次计费” (times), “按量计费” (volume), or `'-'` for unknown.

   • `web/src/components/table/model-pricing/view/table/PricingTableColumns.js`
     – `renderQuotaType` shows “未知” for unknown billing type.

   • `web/src/components/table/models/ModelsColumnDefs.js`
     – Unified `renderQuotaType` to return `'-'` when type is unknown.

   • `web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx`
     – Group price table honors unknown billing type; pricing columns show `'-'` and neutral tag color.

3. **Utilities**
   • Added safe fallback colours/tags for unknown billing type across affected components.

Impact
------
• Ensures correct data aggregation for non-exact model matches.
• Prevents UI from implying volume billing when actual type is ambiguous.
• Provides consistent placeholder display (`'-'` or “未知”) across cards, tables and modals.

No breaking API changes; frontend gracefully handles legacy values.
2025-08-10 21:09:49 +08:00
CaIon
92022360de feat: Update request URL handling for Azure responses based on base URL 2025-08-10 21:09:16 +08:00
CaIon
7f462a084c feat: Add ChannelOtherSettings to manage additional channel configurations 2025-08-10 20:21:30 +08:00
creamlike1024
b77d64bc9f fix: 注册时发送邮件验证码没有等待时间 2025-08-10 19:15:26 +08:00
t0ng7u
d1d945eaa0 🔠 refactor: refine group label formatting in price info
Summary:
• Updated `helpers/utils.js` to display the “group” label without a colon, ensuring consistent typography with other price elements.

Details:
1. `formatPriceInfo`
   – Changed `{t('分组')}:` to `{t('分组')}` for a cleaner look.
   – Keeps spacing intact between label and selected group name.
2. No functional impact; purely visual polish.
2025-08-10 17:17:49 +08:00
feitianbubu
ef0780c096 feat: if video cannot play open in a new tab 2025-08-10 17:07:29 +08:00
feitianbubu
28fdb8af37 feat: add jimeng video official api 2025-08-10 16:54:44 +08:00
t0ng7u
dbde044213 📱 style: Hide vendor introduction on mobile devices
Summary:
• Updated `PricingTopSection.jsx` to conditionally render `PricingVendorIntroWithSkeleton` only when `isMobile` is false.

Details:
1. Wrapped vendor-intro block in `!isMobile` check, preventing unnecessary content on small screens.
2. Kept desktop experience unchanged; no impact on other features.
3. Lint check passed with no new issues.

Result:
Cleaner mobile UI with improved performance and visual focus.
2025-08-10 14:10:50 +08:00
t0ng7u
870132a5cb feat: Add tag-based filtering & refactor filter counts logic
Overview:
• Introduced a new “Model Tag” filter across pricing screens
• Refactored `usePricingFilterCounts` to eliminate duplicated logic
• Improved tag handling to be case-insensitive and deduplicated
• Extended utilities to reset & persist the new filter

Details:
1. Added `filterTag` state to `useModelPricingData` and integrated it into all filtering paths.
2. Created reusable `PricingTags` component using `SelectableButtonGroup`.
3. Incorporated tag filter into `PricingSidebar` and mobile `PricingFilterModal`, including reset support.
4. Enhanced `resetPricingFilters` (helpers/utils) to restore tag filter defaults.
5. Refactored `usePricingFilterCounts.js`:
   • Centralized predicate `matchesFilters` to remove redundancy
   • Normalized tag parsing via `normalizeTags` helper
   • Memoized model subsets with concise filter calls
6. Updated lints – zero errors after refactor.

Result:
Users can now filter models by custom tags with consistent UX, and internal logic is cleaner, faster, and easier to extend.
2025-08-10 14:05:25 +08:00
t0ng7u
ffa898c52d 🐛 fix(PricingCardView): hide placeholder dash when no custom tags
Previously, the card view displayed a “-” whenever a model had no custom tags,
because `renderLimitedItems` returned a dash for an empty array.
Now the function is only invoked when `customTags.length > 0`, removing the
unwanted placeholder and keeping the UI clean.

File affected:
- web/src/components/table/model-pricing/view/card/PricingCardView.jsx
2025-08-10 13:45:16 +08:00
t0ng7u
cdf27d60be fix: prevent model name flicker when closing SideSheet
- Delay clearing selectedModel until SideSheet close animation completes
- Prevents brief display of 'AI/Unknown Model' text during closing transition
- Improves user experience by eliminating visual flicker
- Uses 300ms timeout matching Semi UI default animation duration
2025-08-10 13:41:19 +08:00
creamlike1024
6cf84b118b feat: update openai websearch price 2025-08-10 13:37:49 +08:00
CaIon
1cc81deb69 feat: Enhance EditChannelModal with JSONEditor key updates and input reset
- Added unique keys for JSONEditor components to ensure proper re-rendering based on channelId.
- Implemented input reset to clear previous JSON field values when the modal is opened.
2025-08-10 12:22:18 +08:00
t0ng7u
1d578b73ce 🖼️ chore: format code file 2025-08-10 12:11:31 +08:00
nekohy
fdb6a3ce16 feat: Enhance model listing and retrieval with support for Anthropic and Gemini models; refactor routes for better API key handling 2025-08-10 11:44:38 +08:00
t0ng7u
ca1f3c6e4c 🐛 fix(model): allow zero-value updates so tags/description can be cleared 2025-08-10 11:03:39 +08:00
Calcium-Ion
f942361f7b Merge pull request #1496 from Bliod-Cook/feat-linuxdo-minumum-trust-level
feat: allow admin to restrict the minimum linuxdo trust level to register
2025-08-10 10:27:40 +08:00
Calcium-Ion
02fd80b703 Merge pull request #1537 from RedwindA/feat/support-native-gemini-embedding
feat: 支持原生Gemini Embedding格式
2025-08-10 10:26:46 +08:00
Calcium-Ion
d6b03d4760 Merge pull request #1541 from QuantumNous/fluent-read
feat: added "流畅阅读" (FluentRead) as a new chat provider option.
2025-08-10 10:26:22 +08:00
t0ng7u
cb75e25a1a feat: Add model icon support across backend and UI; prefer model icon over vendor; add icon column in Models table
Backend:
- Model: Add `icon` field to `model.Model` (gorm: varchar(128)); auto-migrated via GORM.
- Pricing API: Extend `model.Pricing` with `icon` and populate from model meta in `GetPricing()`.

Frontend:
- EditModelModal: Add `icon` input (with @lobehub/icons helper link); wire into init/load/submit flows.
- ModelHeader / PricingCardView: Prefer rendering `model.icon`; fallback to `vendor_icon`; final fallback to initials avatar.
- Models table: Add leading “Icon” column, rendering `model.icon` or `vendor` icon via `getLobeHubIcon`.

Notes:
- Backward-compatible. Existing data without `icon` remain unaffected.
- No manual SQL needed; column is added by AutoMigrate.

Affected files:
- model/model_meta.go
- model/pricing.go
- web/src/components/table/models/modals/EditModelModal.jsx
- web/src/components/table/model-pricing/modal/components/ModelHeader.jsx
- web/src/components/table/model-pricing/view/card/PricingCardView.jsx
- web/src/components/table/models/ModelsColumnDefs.js
2025-08-10 01:38:59 +08:00
t0ng7u
9572e16dcb feat: Support dot‑chained props for LobeHub icons
- render.js: Enhance getLobeHubIcon to parse dot‑chained props, e.g.:
  - OpenAI.Avatar.type={'platform'}
  - OpenRouter.Avatar.shape={'square'}
  - Parses booleans/numbers/strings and {…} wrappers; keeps the 2nd arg `size` unless overridden by chain props. Backward compatible.
- EditVendorModal.jsx: Update UI copy — simplify placeholder; document chain‑parameter examples in extra text with doc link.
- en.json: Fix invalid escape sequences in the new i18n string to satisfy linter.

No behavioral changes outside icon rendering; lints pass.
2025-08-10 01:18:36 +08:00
t0ng7u
459fce196f 🍎 chore: modify pagination pageSizeOpts 2025-08-10 00:58:17 +08:00
t0ng7u
ada434fb20 🎨 refactor: MultiKeyManageModal: cleaner stats UI, remove chart, integrate toolbar/pagination, and improve UX
- Replace custom dots with Semi Badge types (success/danger/warning); add compact Progress bars
- Remove pie chart and related deps/config; move total key count and mode tags into the modal title
- Rework header using Row/Col; three equal stat cards (enabled/manual-disabled/auto-disabled)
- Integrate toolbar into Table title; wrap content with Card; use Table’s native empty state
- Make “Enable All” conditional (hidden when all keys are enabled), mirroring “Disable All”
- Unify numeric typography (current/total same size) for better readability
- Default page size set to 10; fallback to 10 when backend page_size is absent; page-size options: 10/20/50/100
- Cleanup imports and dead code (remove VChart and pie-spec logic)
- Minor spacing polish (extra bottom margin before table), no footer buttons
2025-08-10 00:55:18 +08:00
t0ng7u
71ba3fa310 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-09 21:41:33 +08:00
t0ng7u
0727353afa 💄 style(ui): replace inline gradients with reusable pastel blur balls; improve dark mode
- Introduce a global CSS utility `with-pastel-balls` in `web/src/index.css`, rendering pastel “blur balls” via ::before with CSS variables (`--pb1..--pb4`, `--pb-opacity`, `--pb-blur`) for easy theming.
- Apply the utility to pricing header cards and skeletons:
  - `web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx`
  - `web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx`
- Remove per-component inline `linear-gradient(...)` backgrounds and redundant absolute-positioned decoration nodes to reduce duplication.
- Dark mode:
  - Keep the same pastel palette (pink/lavender/mint/peach).
  - Increase visibility with `--pb-opacity: 0.36`, `--pb-blur: 65px`, and `mix-blend-mode: screen`.
- No functional logic changes; UI-only. Lint passes.

Affected files:
- web/src/index.css
- web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx
2025-08-09 21:40:32 +08:00
CaIon
fd2ff2a973 feat: update Fluent Read link handling in sidebar to improve chat item filtering 2025-08-09 21:06:25 +08:00
CaIon
50f9195f2d feat: remove custom JSON marshaling for Message struct 2025-08-09 21:04:49 +08:00
CaIon
a47fc5a76b feat: enhance JSON marshaling for Message and skip Fluent Read links in chat items 2025-08-09 20:58:28 +08:00
CaIon
72ffe61ad1 feat: add verbosity field to OpenAI request #1540 2025-08-09 20:12:27 +08:00
CaIon
ea8cac7c10 feat: add AWS invoke error handling and new error code 2025-08-09 19:26:41 +08:00
CaIon
8639699d49 feat: improve FluentRead notification handling and user prompts 2025-08-09 18:37:08 +08:00
RedwindA
f242220132 feat: update dto for embeddings 2025-08-09 18:31:56 +08:00
CaIon
55dbdba636 feat: add FluentRead support in chat configuration 2025-08-09 18:26:45 +08:00
RedwindA
03b670971b Merge branch 'alpha' into 'feat/support-native-gemini-embedding' 2025-08-09 18:05:11 +08:00
CaIon
24860fdc05 feat: update channel label to indicate deprecation and suggest alternative 2025-08-09 17:51:54 +08:00
CaIon
229dd3a123 feat: update relay-text to conditionally include usage based on StreamOptions #696 2025-08-09 17:51:49 +08:00
CaIon
919eacd907 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-09 17:02:20 +08:00
CaIon
4cec55c9a4 feat: enhance TokensPage and useTokensData to support Fluent integration and notifications 2025-08-09 17:02:06 +08:00
t0ng7u
aa8ec92976 🧹 Refactor: remove redundant code and simplify renderers
- Users table (UsersColumnDefs.js):
  - Remove unused quota calculations from the status renderer
  - Keep status Tag minimal; tooltip shows request count only
  - No functional changes

- Tokens table (TokensColumnDefs.js):
  - Simplify chats menu parsing from localStorage; remove redundant flags/loops
  - Remove unused variables and console statements
  - Keep error handling via showError; preserve existing operations behavior

- General:
  - Codebase tidying only; no UI/logic changes beyond cleanup
  - Lint passes successfully
2025-08-09 16:51:09 +08:00
t0ng7u
44da9c9a28 style(ui): Replace switches with buttons; add quota column with Popover; cleanup
- Tokens/Users tables:
  - Replaced status Switch with explicit Enable/Disable buttons in the operation column
  - Unified button styles with Channels/Models (Disable: danger + small; Enable: default + small)
  - Status column now shows a small Tag only; standardized labels (Enabled/Disabled/etc.); removed usage info

- New "Remaining/Total Quota" column:
  - Wrapped in a white Tag; shows Remaining/Total with a progress bar
  - Replaced Tooltip with Popover; contents use Typography.Paragraph with copyable values
  - Copyable content excludes percentages (only numeric quota values are copied)
  - Added padding to Popover content for better readability

- Tokens specifics:
  - For unlimited quota, show a white Tag "Unlimited quota" with a Popover that displays copyable "Used quota"

- Cleanup:
  - Removed Switch imports/handlers and unused code paths
  - Eliminated console logs and redundant flags; simplified chats parsing
  - Removed quota calculations from status renderers

Files:
- web/src/components/table/tokens/TokensColumnDefs.js
- web/src/components/table/users/UsersColumnDefs.js
2025-08-09 16:47:14 +08:00
t0ng7u
c776a1edff 🐛 fix(db): allow re-adding models & vendors after soft delete; add safe index cleanup
Replace legacy single-column unique indexes with composite unique indexes on
(name, deleted_at) and introduce a safe index drop utility to eliminate
duplicate-key errors and noisy MySQL 1091 warnings.

WHAT
• model/model_meta.go
  - Model.ModelName  → `uniqueIndex:uk_model_name,priority:1`
  - Model.DeletedAt  → `index; uniqueIndex:uk_model_name,priority:2`
• model/vendor_meta.go
  - Vendor.Name      → `uniqueIndex:uk_vendor_name,priority:1`
  - Vendor.DeletedAt → `index; uniqueIndex:uk_vendor_name,priority:2`
• model/main.go
  - Add `dropIndexIfExists(table, index)`:
    • Checks `information_schema.statistics`
    • Drops index only when present (avoids Error 1091)
  - Invoke helper in `migrateDB` & `migrateDBFast`
  - Remove direct `ALTER TABLE … DROP INDEX …` calls

WHY
• Users received `Error 1062 (23000)` when re-creating a soft-deleted
  model/vendor because the old unique index enforced uniqueness on name alone.
• Directly dropping nonexistent indexes caused MySQL `Error 1091` noise.

HOW
• Composite unique indexes `(model_name, deleted_at)` / `(name, deleted_at)`
  respect GORM soft deletes.
• Safe helper ensures idempotent migrations across environments.

RESULT
• Users can now delete and re-add the same model or vendor without manual SQL.
• Startup migration runs quietly across MySQL, PostgreSQL, and SQLite.
• No behavior changes for existing data beyond index updates.

TEST
1. Add model “deepseek-chat” → delete (soft) → re-add → success.
2. Add vendor “DeepSeek”     → delete (soft) → re-add → success.
3. Restart service twice → no duplicate key or 1091 errors.
2025-08-09 15:44:08 +08:00
t0ng7u
a5cbef1a61 style(JSONEditor): add AGPL-3.0 license header, clean imports & refine Banner UI
* Added full GNU Affero General Public License v3 header at the top of `JSONEditor.js`.
* Removed unused `IconCode` and `IconRefresh` imports to eliminate dead code.
* Set `closeIcon={null}` and applied `!rounded-md` class for `Banner`, improving visual consistency and preventing unintended dismissal.
* Normalized whitespace and line-breaks for better readability and lint compliance.
2025-08-09 14:08:28 +08:00
同語
ae22ba593a feat: optimized JSONEditor in duplicate key handling
Merge pull request #1534 from HynoR/feat/je
2025-08-09 13:22:16 +08:00
t0ng7u
4ad4ad7088 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-09 13:10:20 +08:00
t0ng7u
8bccda5649 🐛 fix(db): allow re-adding models/vendors after soft delete via composite unique indexes
Ensure models and vendors can be re-created after soft deletion by switching to composite unique indexes on (name, deleted_at) and cleaning up legacy single-column unique indexes on MySQL.

Why
- MySQL raised 1062 duplicate key errors when re-adding a soft-deleted model/vendor because the legacy unique index enforced uniqueness on the name column alone (uk_model_name / uk_vendor_name), despite soft deletes.
- Users encountered errors such as:
  - Error 1062 (23000): Duplicate entry 'deepseek-chat' for key 'models.uk_model_name'
  - Error 1062 (23000): Duplicate entry 'DeepSeek' for key 'vendors.uk_vendor_name'

How
- Model indices:
  - model/model_meta.go:
    - Model.ModelName → gorm: uniqueIndex:uk_model_name,priority:1
    - Model.DeletedAt → gorm: index; uniqueIndex:uk_model_name,priority:2
- Vendor indices:
  - model/vendor_meta.go:
    - Vendor.Name → gorm: uniqueIndex:uk_vendor_name,priority:1
    - Vendor.DeletedAt → gorm: index; uniqueIndex:uk_vendor_name,priority:2
- Migration (automatic, idempotent):
  - model/main.go (migrateDB, migrateDBFast):
    - On MySQL, drop legacy single-column unique indexes if present:
      - ALTER TABLE models DROP INDEX uk_model_name;
      - ALTER TABLE vendors DROP INDEX uk_vendor_name;
    - Then run AutoMigrate to create composite unique indexes.
  - Missing-index errors are ignored to keep the migration safe to run multiple times.

Result
- Users can delete and re-add the same model/vendor name without manual SQL.
- Migration runs automatically at startup; no user action required.
- PostgreSQL and SQLite remain unaffected.

Files changed
- model/model_meta.go
- model/vendor_meta.go
- model/main.go (migrateDB, migrateDBFast)

Testing
- Create model "deepseek-chat" → delete (soft) → re-create → succeeds.
- Create vendor "DeepSeek" → delete (soft) → re-create → succeeds.

Backward compatibility
- Data remains intact; only index definitions are updated.
- Behavior is unchanged except for fixing the uniqueness constraint with soft deletes.
2025-08-09 13:07:57 +08:00
RedwindA
7a31e481a6 fix typo; add ParamOverride for Gemini Embedding 2025-08-09 01:07:48 +08:00
RedwindA
b70d2655ed feat: support native Gemini Embedding 2025-08-09 00:27:33 +08:00
HynoR
2471367c92 feat: optimized Json Visual Editor(JSONEditor) when detected duplicate key 2025-08-08 19:00:02 +08:00
Bliod-Cook
3feeca627c feat: allow admin to restrict the minimum linuxdo trust level to register 2025-08-04 17:19:38 +08:00
feitianbubu
2488e6ab66 feat: add ali qwen channel autoDisabled 2025-07-19 21:19:46 +08:00
feitianbubu
f3b7ac508d feat: add kling video text2Video when image is empty 2025-07-19 10:16:31 +08:00
Xyfacai
cd7594f623 feat: dalle 格式支持自定义参数 2025-06-09 22:14:51 +08:00
lollipopkit🏳️‍⚧️
6c4242ad2a Merge branch 'QuantumNous:main' into main 2025-06-05 18:26:43 +08:00
wzxjohn
da98972dda feat: support UMAMI analytics 2025-05-16 16:44:47 +08:00
lollipopkit🏳️‍⚧️
530af5e358 feat: /api/token/usage 2025-04-29 17:13:28 +08:00
713 changed files with 64579 additions and 23759 deletions

View File

@@ -5,4 +5,5 @@
.gitignore
Makefile
docs
.eslintcache
.eslintcache
.gocache

View File

@@ -47,7 +47,7 @@
# 所有请求超时时间单位秒默认为0表示不限制
# RELAY_TIMEOUT=0
# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
# STREAMING_TIMEOUT=120
# STREAMING_TIMEOUT=300
# Gemini 识别图片 最大图片数量
# GEMINI_VISION_MAX_IMAGE_NUM=16
@@ -56,8 +56,6 @@
# SESSION_SECRET=random_string
# 其他配置
# 渠道测试频率(单位:秒)
# CHANNEL_TEST_FREQUENCY=10
# 生成默认token
# GENERATE_DEFAULT_TOKEN=false
# Cohere 安全设置

26
.github/ISSUE_TEMPLATE/bug_report_en.md vendored Normal file
View File

@@ -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.

View File

@@ -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**

View File

@@ -13,7 +13,3 @@
### PR 描述
**请在下方详细描述您的 PR包括目的、实现细节等。**
### **重要提示**
**所有 PR 都必须提交到 `alpha` 分支。请确保您的 PR 目标分支是 `alpha`。**

View File

@@ -11,19 +11,42 @@ on:
required: false
jobs:
push_to_registries:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
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 the repo
- name: Check out (shallow)
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Save version info
- name: Determine alpha version
id: version
run: |
echo "alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" > VERSION
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
@@ -31,32 +54,98 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the Container registry
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
- name: Extract metadata (labels)
id: meta
uses: docker/metadata-action@v5
with:
images: |
calciumion/new-api
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=alpha
type=raw,value=alpha-{{date 'YYYYMMDD'}}-{{sha}}
ghcr.io/${{ env.GHCR_REPOSITORY }}
- name: Build and push Docker images
uses: docker/build-push-action@v5
- name: Build & push single-arch (to both registries)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
platforms: ${{ matrix.platform }}
push: true
tags: ${{ steps.meta.outputs.tags }}
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

View File

@@ -1,26 +1,45 @@
name: Publish Docker image (Multi Registries)
name: Publish Docker image (Multi Registries, native amd64+arm64)
on:
push:
tags:
- '*'
jobs:
push_to_registries:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
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 the repo
- name: Check out (shallow)
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Save version info
- name: Resolve tag & write VERSION
run: |
git describe --tags > VERSION
git fetch --tags --force --depth=1
echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
echo "$TAG" > VERSION
echo "Building tag: $TAG for ${{ matrix.arch }}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# - name: Normalize GHCR repository
# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -31,26 +50,88 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_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 (tags, labels) for Docker
- name: Extract metadata (labels)
id: meta
uses: docker/metadata-action@v5
with:
images: |
calciumion/new-api
ghcr.io/${{ github.repository }}
# ghcr.io/${{ env.GHCR_REPOSITORY }}
- name: Build and push Docker images
uses: docker/build-push-action@v5
- name: Build & push single-arch (to both registries)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
platforms: ${{ matrix.platform }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
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

142
.github/workflows/electron-build.yml vendored Normal file
View File

@@ -0,0 +1,142 @@
name: Build Electron App
on:
push:
tags:
- '*' # Triggers on version tags like v1.0.0
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: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
windows-build/*
draft: false
prerelease: false
overwrite_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,59 +0,0 @@
name: Linux Release
permissions:
contents: write
on:
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- 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=$(git describe --tags) bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend (amd64)
run: |
go mod download
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api
- 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 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api-arm64
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
one-api
one-api-arm64
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,51 +0,0 @@
name: macOS Release
permissions:
contents: write
on:
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- 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=$(git describe --tags) bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend
run: |
go mod download
go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o one-api-macos
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: one-api-macos
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,21 +0,0 @@
name: Check PR Branching Strategy
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
check-branching-strategy:
runs-on: ubuntu-latest
steps:
- name: Enforce branching strategy
run: |
if [[ "${{ github.base_ref }}" == "main" ]]; then
if [[ "${{ github.head_ref }}" != "alpha" ]]; then
echo "Error: Pull requests to 'main' are only allowed from the 'alpha' branch."
exit 1
fi
elif [[ "${{ github.base_ref }}" != "alpha" ]]; then
echo "Error: Pull requests must be targeted to the 'alpha' or 'main' branch."
exit 1
fi
echo "Branching strategy check passed."

142
.github/workflows/release.yml vendored Normal file
View File

@@ -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
- 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=$(git describe --tags) 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
VERSION=$(git describe --tags)
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
VERSION=$(git describe --tags)
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-*
draft: true
generate_release_notes: true
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
- 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=$(git describe --tags) 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
VERSION=$(git describe --tags)
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-*
draft: true
generate_release_notes: true
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
- 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=$(git describe --tags) 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
VERSION=$(git describe --tags)
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
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

91
.github/workflows/sync-to-gitee.yml vendored Normal file
View File

@@ -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<<EOF"
echo "$RELEASE_INFO" | jq -r '.body'
echo "EOF"
} >> $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 }}"

View File

@@ -1,53 +0,0 @@
name: Windows Release
permissions:
contents: write
on:
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: windows-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- 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=$(git describe --tags) bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend
run: |
go mod download
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o one-api.exe
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: one-api.exe
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

7
.gitignore vendored
View File

@@ -9,6 +9,11 @@ logs
web/dist
.env
one-api
new-api
.DS_Store
tiktoken_cache
.eslintcache
.eslintcache
.gocache
electron/node_modules
electron/dist

View File

@@ -9,10 +9,12 @@ 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}
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux
WORKDIR /build
@@ -21,7 +23,7 @@ RUN go mod download
COPY . .
COPY --from=builder /build/dist ./web/dist
RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)'" -o one-api
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
FROM alpine
@@ -29,7 +31,7 @@ RUN apk upgrade --no-cache \
&& apk add --no-cache ca-certificates tzdata ffmpeg \
&& update-ca-certificates
COPY --from=builder2 /build/one-api /
COPY --from=builder2 /build/new-api /
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/one-api"]
ENTRYPOINT ["/new-api"]

View File

@@ -1,6 +1,10 @@
<p align="right">
<a href="./README.md">中文</a> | <strong>English</strong>
<a href="./README.md">中文</a> | <strong>English</strong> | <a href="./README.fr.md">Français</a> | <a href="./README.ja.md">日本語</a>
</p>
> [!NOTE]
> **MT (Machine Translation)**: This document is machine translated. For the most accurate information, please refer to the [Chinese version](./README.md).
<div align="center">
![new-api](/web/public/logo.png)
@@ -40,6 +44,28 @@
> - 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.
<h2>🤝 Trusted Partners</h2>
<p id="premium-sponsors">&nbsp;</p>
<p align="center"><strong>No particular order</strong></p>
<p align="center">
<a href="https://www.cherry-ai.com/" target=_blank><img
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
/></a>
<a href="https://bda.pku.edu.cn/" target=_blank><img
src="./docs/images/pku.png" alt="Peking University" height="120"
/></a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
src="./docs/images/ucloud.png" alt="UCloud" height="120"
/></a>
<a href="https://www.aliyun.com/" target=_blank><img
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
/></a>
<a href="https://io.net/" target=_blank><img
src="./docs/images/io-net.png" alt="IO.NET" height="120"
/></a>
</p>
<p>&nbsp;</p>
## 📚 Documentation
For detailed documentation, please visit our official Wiki: [https://docs.newapi.pro/](https://docs.newapi.pro/)
@@ -53,7 +79,7 @@ New API offers a wide range of features, please refer to [Features Introduction]
1. 🎨 Brand new UI interface
2. 🌍 Multi-language support
3. 💰 Online recharge functionality (YiPay)
3. 💰 Online recharge functionality, currently supports EPay and Stripe
4. 🔍 Support for querying usage quotas with keys (works with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
5. 🔄 Compatible with the original One API database
6. 💵 Support for pay-per-use model pricing
@@ -63,18 +89,23 @@ New API offers a wide range of features, please refer to [Features Introduction]
10. 🤖 Support for more authorization login methods (LinuxDO, Telegram, OIDC)
11. 🔄 Support for Rerank models (Cohere and Jina), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
12. ⚡ Support for OpenAI Realtime API (including Azure channels), [API Documentation](https://docs.newapi.pro/api/openai-realtime)
13. ⚡ Support for Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
14. Support for entering chat interface via /chat2link route
15. 🧠 Support for setting reasoning effort through model name suffixes:
13. ⚡ Support for **OpenAI Responses** format, [API Documentation](https://docs.newapi.pro/api/openai-responses)
14. Support for **Claude Messages** format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
15. Support for **Google Gemini** format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/)
16. 🧠 Support for setting reasoning effort through model name suffixes:
1. OpenAI o-series models
- Add `-high` suffix for high reasoning effort (e.g.: `o3-mini-high`)
- Add `-medium` suffix for medium reasoning effort (e.g.: `o3-mini-medium`)
- Add `-low` suffix for low reasoning effort (e.g.: `o3-mini-low`)
2. Claude thinking models
- Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`)
16. 🔄 Thinking-to-content functionality
17. 🔄 Model rate limiting for users
18. 💰 Cache billing support, which allows billing at a set ratio when cache is hit:
17. 🔄 Thinking-to-content functionality
18. 🔄 Model rate limiting for users
19. 🔄 Request format conversion functionality, supporting the following three format conversions:
1. OpenAI Chat Completions => Claude Messages
2. Claude Messages => OpenAI Chat Completions (can be used for Claude Code to call third-party models)
3. OpenAI Chat Completions => Gemini Chat
20. 💰 Cache billing support, which allows billing at a set ratio when cache is hit:
1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings`
2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit
3. Supported channels:
@@ -93,23 +124,23 @@ This version supports multiple models, please refer to [API Documentation-Relay
4. Custom channels, supporting full call address input
5. Rerank models ([Cohere](https://cohere.ai/) and [Jina](https://jina.ai/)), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
6. Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
7. Dify, currently only supports chatflow
7. Google Gemini format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/)
8. Dify, currently only supports chatflow
9. For more interfaces, please refer to [API Documentation](https://docs.newapi.pro/api)
## Environment Variable Configuration
For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables):
- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 120 seconds
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 300 seconds
- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
- `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true`
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`
- `GET_MEDIA_TOKEN_NOT_STREAM`: Whether to count image tokens in non-streaming cases, default is `true`
- `UPDATE_TASK`: Whether to update asynchronous tasks (Midjourney, Suno), default is `true`
- `COHERE_SAFETY_SETTING`: Cohere model safety settings, options are `NONE`, `CONTEXTUAL`, `STRICT`, default is `NONE`
- `GEMINI_VISION_MAX_IMAGE_NUM`: Maximum number of images for Gemini models, default is `16`
- `MAX_FILE_DOWNLOAD_MB`: Maximum file download size in MB, default is `20`
- `CRYPTO_SECRET`: Encryption key used for encrypting database content
- `CRYPTO_SECRET`: Encryption key used for encrypting Redis database content
- `AZURE_DEFAULT_API_VERSION`: Azure channel default API version, default is `2025-04-01-preview`
- `NOTIFICATION_LIMIT_DURATION_MINUTE`: Notification limit duration, default is `10` minutes
- `NOTIFY_LIMIT_COUNT`: Maximum number of user notifications within the specified duration, default is `2`
@@ -156,7 +187,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
```
## Channel Retry and Cache
Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings`. It is **recommended to enable caching**.
Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings->Failure Retry Count`, **recommended to enable caching** functionality.
### Cache Configuration Method
1. `REDIS_CONN_STRING`: Set Redis as cache
@@ -166,21 +197,21 @@ Channel retry functionality has been implemented, you can set the number of retr
For detailed API documentation, please refer to [API Documentation](https://docs.newapi.pro/api):
- [Chat API](https://docs.newapi.pro/api/openai-chat)
- [Image API](https://docs.newapi.pro/api/openai-image)
- [Rerank API](https://docs.newapi.pro/api/jinaai-rerank)
- [Realtime API](https://docs.newapi.pro/api/openai-realtime)
- [Claude Chat API (messages)](https://docs.newapi.pro/api/anthropic-chat)
- [Chat API (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [Response API (Responses)](https://docs.newapi.pro/api/openai-responses)
- [Image API (Image)](https://docs.newapi.pro/api/openai-image)
- [Rerank API (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [Realtime Chat API (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [Claude Chat API](https://docs.newapi.pro/api/anthropic-chat)
- [Google Gemini Chat API](https://docs.newapi.pro/api/google-gemini-chat)
## Related Projects
- [One API](https://github.com/songquanpeng/one-api): Original project
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy): Midjourney interface support
- [chatnio](https://github.com/Deeptrain-Community/chatnio): Next-generation AI one-stop B/C-end solution
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool): Query usage quota with key
Other projects based on New API:
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon): High-performance optimized version of New API
- [VoAPI](https://github.com/VoAPI/VoAPI): Frontend beautified version based on New API
## Help and Support
@@ -189,24 +220,6 @@ If you have any questions, please refer to [Help and Support](https://docs.newap
- [Issue Feedback](https://docs.newapi.pro/support/feedback-issues)
- [FAQ](https://docs.newapi.pro/support/faq)
## 🤝 Trusted Partners
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank"><img
src="./docs/images/cherry-studio.svg" alt="Cherry Studio" height="58"
/></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://bda.pku.edu.cn/" target="_blank"><img
src="./docs/images/pku.png" alt="Peking University" height="58"
/></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank"><img
src="./docs/images/ucloud.svg" alt="UCloud" height="58"
/></a>
</p>
<p align="center"><em>No particular order</em></p>
## 🌟 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)

225
README.fr.md Normal file
View File

@@ -0,0 +1,225 @@
<p align="right">
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <strong>Français</strong> | <a href="./README.ja.md">日本語</a>
</p>
> [!NOTE]
> **MT (Traduction Automatique)**: Ce document est traduit automatiquement. Pour les informations les plus précises, veuillez vous référer à la [version chinoise](./README.md).
<div align="center">
![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
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<p align="center">
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
<img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="licence">
</a>
<a href="https://github.com/Calcium-Ion/new-api/releases/latest">
<img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="version">
</a>
<a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
</a>
<a href="https://hub.docker.com/r/CalciumIon/new-api">
<img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
</a>
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
</p>
</div>
## 📝 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.
<h2>🤝 Partenaires de confiance</h2>
<p id="premium-sponsors">&nbsp;</p>
<p align="center"><strong>Sans ordre particulier</strong></p>
<p align="center">
<a href="https://www.cherry-ai.com/" target=_blank><img
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
/></a>
<a href="https://bda.pku.edu.cn/" target=_blank><img
src="./docs/images/pku.png" alt="Université de Pékin" height="120"
/></a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
src="./docs/images/ucloud.png" alt="UCloud" height="120"
/></a>
<a href="https://www.aliyun.com/" target=_blank><img
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
/></a>
<a href="https://io.net/" target=_blank><img
src="./docs/images/io-net.png" alt="IO.NET" height="120"
/></a>
</p>
<p>&nbsp;</p>
## 📚 Documentation
Pour une documentation détaillée, veuillez consulter notre Wiki officiel : [https://docs.newapi.pro/](https://docs.newapi.pro/)
Vous pouvez également accéder au DeepWiki généré par l'IA :
[![Demander à DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
## ✨ Fonctionnalités clés
New API offre un large éventail de fonctionnalités, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/wiki/features-introduction) pour plus de détails :
1. 🎨 Nouvelle interface utilisateur
2. 🌍 Prise en charge multilingue
3. 💰 Fonctionnalité de recharge en ligne, prend actuellement en charge EPay et Stripe
4. 🔍 Prise en charge de la recherche de quotas d'utilisation avec des clés (fonctionne avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
5. 🔄 Compatible avec la base de données originale de One API
6. 💵 Prise en charge de la tarification des modèles de paiement à l'utilisation
7. ⚖️ Prise en charge de la sélection aléatoire pondérée des canaux
8. 📈 Tableau de bord des données (console)
9. 🔒 Regroupement de jetons et restrictions de modèles
10. 🤖 Prise en charge de plus de méthodes de connexion par autorisation (LinuxDO, Telegram, OIDC)
11. 🔄 Prise en charge des modèles Rerank (Cohere et Jina), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
12. ⚡ Prise en charge de l'API OpenAI Realtime (y compris les canaux Azure), [Documentation de l'API](https://docs.newapi.pro/api/openai-realtime)
13. ⚡ Prise en charge du format **OpenAI Responses**, [Documentation de l'API](https://docs.newapi.pro/api/openai-responses)
14. ⚡ Prise en charge du format **Claude Messages**, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
15. ⚡ Prise en charge du format **Google Gemini**, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/)
16. 🧠 Prise en charge de la définition de l'effort de raisonnement via les suffixes de nom de modèle :
1. Modèles de la série o d'OpenAI
- Ajouter le suffixe `-high` pour un effort de raisonnement élevé (par exemple : `o3-mini-high`)
- Ajouter le suffixe `-medium` pour un effort de raisonnement moyen (par exemple : `o3-mini-medium`)
- Ajouter le suffixe `-low` pour un effort de raisonnement faible (par exemple : `o3-mini-low`)
2. Modèles de pensée de Claude
- Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`)
17. 🔄 Fonctionnalité de la pensée au contenu
18. 🔄 Limitation du débit du modèle pour les utilisateurs
19. 🔄 Fonctionnalité de conversion de format de requête, prenant en charge les trois conversions de format suivantes :
1. OpenAI Chat Completions => Claude Messages
2. Claude Messages => OpenAI Chat Completions (peut être utilisé pour Claude Code pour appeler des modèles tiers)
3. OpenAI Chat Completions => Gemini Chat
20. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint :
1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement`
2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint
3. Canaux pris en charge :
- [x] OpenAI
- [x] Azure
- [x] DeepSeek
- [x] Claude
## Prise en charge des modèles
Cette version prend en charge plusieurs modèles, veuillez vous référer à [Documentation de l'API-Interface de relais](https://docs.newapi.pro/api) pour plus de détails :
1. Modèles tiers **gpts** (gpt-4-gizmo-*)
2. Canal tiers [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy), [Documentation de l'API](https://docs.newapi.pro/api/midjourney-proxy-image)
3. Canal tiers [Suno API](https://github.com/Suno-API/Suno-API), [Documentation de l'API](https://docs.newapi.pro/api/suno-music)
4. Canaux personnalisés, prenant en charge la saisie complète de l'adresse d'appel
5. Modèles Rerank ([Cohere](https://cohere.ai/) et [Jina](https://jina.ai/)), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
6. Format de messages Claude, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
7. Format Google Gemini, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/)
8. Dify, ne prend actuellement en charge que chatflow
9. Pour plus d'interfaces, veuillez vous référer à la [Documentation de l'API](https://docs.newapi.pro/api)
## Configuration des variables d'environnement
Pour des instructions de configuration détaillées, veuillez vous référer à [Guide d'installation-Configuration des variables d'environnement](https://docs.newapi.pro/installation/environment-variables) :
- `GENERATE_DEFAULT_TOKEN` : S'il faut générer des jetons initiaux pour les utilisateurs nouvellement enregistrés, la valeur par défaut est `false`
- `STREAMING_TIMEOUT` : Délai d'expiration de la réponse en streaming, la valeur par défaut est de 300 secondes
- `DIFY_DEBUG` : S'il faut afficher les informations sur le flux de travail et les nœuds pour les canaux Dify, la valeur par défaut est `true`
- `GET_MEDIA_TOKEN` : S'il faut compter les jetons d'image, la valeur par défaut est `true`
- `GET_MEDIA_TOKEN_NOT_STREAM` : S'il faut compter les jetons d'image dans les cas sans streaming, la valeur par défaut est `true`
- `UPDATE_TASK` : S'il faut mettre à jour les tâches asynchrones (Midjourney, Suno), la valeur par défaut est `true`
- `GEMINI_VISION_MAX_IMAGE_NUM` : Nombre maximum d'images pour les modèles Gemini, la valeur par défaut est `16`
- `MAX_FILE_DOWNLOAD_MB` : Taille maximale de téléchargement de fichier en Mo, la valeur par défaut est `20`
- `CRYPTO_SECRET` : Clé de chiffrement utilisée pour chiffrer le contenu de la base de données Redis
- `AZURE_DEFAULT_API_VERSION` : Version de l'API par défaut du canal Azure, la valeur par défaut est `2025-04-01-preview`
- `NOTIFICATION_LIMIT_DURATION_MINUTE` : Durée de la limite de notification, la valeur par défaut est de `10` minutes
- `NOTIFY_LIMIT_COUNT` : Nombre maximal de notifications utilisateur dans la durée spécifiée, la valeur par défaut est `2`
- `ERROR_LOG_ENABLED=true` : S'il faut enregistrer et afficher les journaux d'erreurs, la valeur par défaut est `false`
## Déploiement
Pour des guides de déploiement détaillés, veuillez vous référer à [Guide d'installation-Méthodes de déploiement](https://docs.newapi.pro/installation) :
> [!TIP]
> Dernière image Docker : `calciumion/new-api:latest`
### Considérations sur le déploiement multi-machines
- La variable d'environnement `SESSION_SECRET` doit être définie, sinon l'état de connexion sera incohérent sur plusieurs machines
- Si vous partagez Redis, `CRYPTO_SECRET` doit être défini, sinon le contenu de Redis ne pourra pas être consulté sur plusieurs machines
### Exigences de déploiement
- Base de données locale (par défaut) : SQLite (le déploiement Docker doit monter le répertoire `/data`)
- Base de données distante : MySQL version >= 5.7.8, PgSQL version >= 9.6
### Méthodes de déploiement
#### Utilisation de la fonctionnalité Docker du panneau BaoTa
Installez le panneau BaoTa (version **9.2.0** ou supérieure), recherchez **New-API** dans le magasin d'applications et installez-le.
[Tutoriel avec des images](./docs/BT.md)
#### Utilisation de Docker Compose (recommandé)
```shell
# Télécharger le projet
git clone https://github.com/Calcium-Ion/new-api.git
cd new-api
# Modifier docker-compose.yml si nécessaire
# Démarrer
docker-compose up -d
```
#### Utilisation directe de l'image Docker
```shell
# Utilisation de SQLite
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/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 /home/ubuntu/data/new-api:/data calciumion/new-api:latest
```
## Nouvelle tentative de canal et cache
La fonctionnalité de nouvelle tentative de canal a été implémentée, vous pouvez définir le nombre de tentatives dans `Paramètres->Paramètres de fonctionnement->Paramètres généraux->Nombre de tentatives en cas d'échec`, **recommandé d'activer la fonctionnalité de mise en cache**.
### Méthode de configuration du cache
1. `REDIS_CONN_STRING` : Définir Redis comme cache
2. `MEMORY_CACHE_ENABLED` : Activer le cache mémoire (pas besoin de le définir manuellement si Redis est défini)
## Documentation de l'API
Pour une documentation détaillée de l'API, veuillez vous référer à [Documentation de l'API](https://docs.newapi.pro/api) :
- [API de discussion (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [API de réponse (Responses)](https://docs.newapi.pro/api/openai-responses)
- [API d'image (Image)](https://docs.newapi.pro/api/openai-image)
- [API de rerank (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [API de discussion en temps réel (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [API de discussion Claude](https://docs.newapi.pro/api/anthropic-chat)
- [API de discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat)
## Projets connexes
- [One API](https://github.com/songquanpeng/one-api) : Projet original
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) : Prise en charge de l'interface Midjourney
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) : Interroger le quota d'utilisation avec une clé
Autres projets basés sur New API :
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) : Version optimisée hautes performances de New API
## Aide et support
Si vous avez des questions, veuillez vous référer à [Aide et support](https://docs.newapi.pro/support) :
- [Interaction avec la communauté](https://docs.newapi.pro/support/community-interaction)
- [Commentaires sur les problèmes](https://docs.newapi.pro/support/feedback-issues)
- [FAQ](https://docs.newapi.pro/support/faq)
## 🌟 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)

226
README.ja.md Normal file
View File

@@ -0,0 +1,226 @@
<p align="right">
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a> | <strong>日本語</strong>
</p>
> [!NOTE]
> **MT機械翻訳**: この文書は機械翻訳されています。最も正確な情報については、[中国語版](./README.md)を参照してください。
<div align="center">
![new-api](/web/public/logo.png)
# New API
🍥次世代大規模モデルゲートウェイとAI資産管理システム
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<p align="center">
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
<img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="license">
</a>
<a href="https://github.com/Calcium-Ion/new-api/releases/latest">
<img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="release">
</a>
<a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
</a>
<a href="https://hub.docker.com/r/CalciumIon/new-api">
<img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
</a>
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
</p>
</div>
## 📝 プロジェクト説明
> [!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 サービスを提供しないでください。
<h2>🤝 信頼できるパートナー</h2>
<p id="premium-sponsors">&nbsp;</p>
<p align="center"><strong>順不同</strong></p>
<p align="center">
<a href="https://www.cherry-ai.com/" target=_blank><img
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
/></a>
<a href="https://bda.pku.edu.cn/" target=_blank><img
src="./docs/images/pku.png" alt="北京大学" height="120"
/></a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="120"
/></a>
<a href="https://www.aliyun.com/" target=_blank><img
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
/></a>
<a href="https://io.net/" target=_blank><img
src="./docs/images/io-net.png" alt="IO.NET" height="120"
/></a>
</p>
<p>&nbsp;</p>
## 📚 ドキュメント
詳細なドキュメントは公式Wikiをご覧ください[https://docs.newapi.pro/](https://docs.newapi.pro/)
AIが生成したDeepWikiにもアクセスできます
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
## ✨ 主な機能
New APIは豊富な機能を提供しています。詳細な機能については[機能説明](https://docs.newapi.pro/wiki/features-introduction)を参照してください:
1. 🎨 全く新しいUIインターフェース
2. 🌍 多言語サポート
3. 💰 オンラインチャージ機能をサポート、現在EPayとStripeをサポート
4. 🔍 キーによる使用量クォータの照会をサポート([neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)と連携)
5. 🔄 オリジナルのOne APIデータベースと互換性あり
6. 💵 モデルの従量課金をサポート
7. ⚖️ チャネルの重み付けランダムをサポート
8. 📈 データダッシュボード(コンソール)
9. 🔒 トークングループ化、モデル制限
10. 🤖 より多くの認証ログイン方法をサポートLinuxDO、Telegram、OIDC
11. 🔄 RerankモデルをサポートCohereとJina、[API ドキュメント](https://docs.newapi.pro/api/jinaai-rerank)
12. ⚡ OpenAI Realtime APIをサポートAzureチャネルを含む、[APIドキュメント](https://docs.newapi.pro/api/openai-realtime)
13. ⚡ **OpenAI Responses**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/openai-responses)
14. ⚡ **Claude Messages**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat)
15. ⚡ **Google Gemini**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/)
16. 🧠 モデル名のサフィックスを通じてreasoning effortを設定することをサポート
1. OpenAI oシリーズモデル
- `-high`サフィックスを追加してhigh reasoning effortに設定`o3-mini-high`
- `-medium`サフィックスを追加してmedium reasoning effortに設定`o3-mini-medium`
- `-low`サフィックスを追加してlow reasoning effortに設定`o3-mini-low`
2. Claude思考モデル
- `-thinking`サフィックスを追加して思考モードを有効にする(例:`claude-3-7-sonnet-20250219-thinking`
17. 🔄 思考からコンテンツへの機能
18. 🔄 ユーザーに対するモデルレート制限機能
19. 🔄 リクエストフォーマット変換機能、以下の3つのフォーマット変換をサポート
1. OpenAI Chat Completions => Claude Messages
2. Claude Messages => OpenAI Chat CompletionsClaude Codeがサードパーティモデルを呼び出す際に使用可能
3. OpenAI Chat Completions => Gemini Chat
20. 💰 キャッシュ課金サポート、有効にするとキャッシュがヒットした際に設定された比率で課金できます:
1. `システム設定-運営設定``プロンプトキャッシュ倍率`オプションを設定
2. チャネルで`プロンプトキャッシュ倍率`を設定、範囲は0-1、例えば0.5に設定するとキャッシュがヒットした際に50%で課金
3. サポートされているチャネル:
- [x] OpenAI
- [x] Azure
- [x] DeepSeek
- [x] Claude
## モデルサポート
このバージョンは複数のモデルをサポートしています。詳細は[APIドキュメント-中継インターフェース](https://docs.newapi.pro/api)を参照してください:
1. サードパーティモデル **gpts**gpt-4-gizmo-*
2. サードパーティチャネル[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/midjourney-proxy-image)
3. サードパーティチャネル[Suno API](https://github.com/Suno-API/Suno-API)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/suno-music)
4. カスタムチャネル、完全な呼び出しアドレスの入力をサポート
5. Rerankモデル[Cohere](https://cohere.ai/)と[Jina](https://jina.ai/))、[APIドキュメント](https://docs.newapi.pro/api/jinaai-rerank)
6. Claude Messages形式、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat)
7. Google Gemini形式、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/)
8. Dify、現在はchatflowのみをサポート
9. その他のインターフェースについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください
## 環境変数設定
詳細な設定説明については[インストールガイド-環境変数設定](https://docs.newapi.pro/installation/environment-variables)を参照してください:
- `GENERATE_DEFAULT_TOKEN`:新規登録ユーザーに初期トークンを生成するかどうか、デフォルトは`false`
- `STREAMING_TIMEOUT`ストリーミング応答のタイムアウト時間、デフォルトは300秒
- `DIFY_DEBUG`Difyチャネルがワークフローとード情報を出力するかどうか、デフォルトは`true`
- `GET_MEDIA_TOKEN`:画像トークンを統計するかどうか、デフォルトは`true`
- `GET_MEDIA_TOKEN_NOT_STREAM`:非ストリーミングの場合に画像トークンを統計するかどうか、デフォルトは`true`
- `UPDATE_TASK`非同期タスクMidjourney、Sunoを更新するかどうか、デフォルトは`true`
- `GEMINI_VISION_MAX_IMAGE_NUM`Geminiモデルの最大画像数、デフォルトは`16`
- `MAX_FILE_DOWNLOAD_MB`: 最大ファイルダウンロードサイズ、単位MB、デフォルトは`20`
- `CRYPTO_SECRET`暗号化キー、Redisデータベースの内容を暗号化するために使用
- `AZURE_DEFAULT_API_VERSION`Azureチャネルのデフォルトのバージョン、デフォルトは`2025-04-01-preview`
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:メールなどの通知制限の継続時間、デフォルトは`10`
- `NOTIFY_LIMIT_COUNT`:指定された継続時間内のユーザー通知の最大数、デフォルトは`2`
- `ERROR_LOG_ENABLED=true`: エラーログを記録して表示するかどうか、デフォルトは`false`
## デプロイ
詳細なデプロイガイドについては[インストールガイド-デプロイ方法](https://docs.newapi.pro/installation)を参照してください:
> [!TIP]
> 最新のDockerイメージ`calciumion/new-api:latest`
### マルチマシンデプロイの注意事項
- 環境変数`SESSION_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にログイン状態が不一致になります
- Redisを共有する場合、`CRYPTO_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にRedisの内容を取得できません
### デプロイ要件
- ローカルデータベースデフォルトSQLiteDockerデプロイの場合は`/data`ディレクトリをマウントする必要があります)
- リモートデータベースMySQLバージョン >= 5.7.8、PgSQLバージョン >= 9.6
### デプロイ方法
#### 宝塔パネルのDocker機能を使用してデプロイ
宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を見つけてインストールします。
[画像付きチュートリアル](./docs/BT.md)
#### Docker Composeを使用してデプロイ推奨
```shell
# プロジェクトをダウンロード
git clone https://github.com/Calcium-Ion/new-api.git
cd new-api
# 必要に応じてdocker-compose.ymlを編集
# 起動
docker-compose up -d
```
#### Dockerイメージを直接使用
```shell
# SQLiteを使用
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/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 /home/ubuntu/data/new-api:/data calciumion/new-api:latest
```
## チャネルリトライとキャッシュ
チャネルリトライ機能はすでに実装されており、`設定->運営設定->一般設定->失敗リトライ回数`でリトライ回数を設定できます。**キャッシュ機能を有効にすることを推奨します**。
### キャッシュ設定方法
1. `REDIS_CONN_STRING`Redisをキャッシュとして設定
2. `MEMORY_CACHE_ENABLED`メモリキャッシュを有効にするRedisを設定した場合は手動設定不要
## APIドキュメント
詳細なAPIドキュメントについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください:
- [チャットインターフェース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)
- [再ランク付けインターフェース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)
## 関連プロジェクト
- [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ベースのその他のプロジェクト
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon)New API高性能最適化版
## ヘルプサポート
問題がある場合は、[ヘルプサポート](https://docs.newapi.pro/support)を参照してください:
- [コミュニティ交流](https://docs.newapi.pro/support/community-interaction)
- [問題のフィードバック](https://docs.newapi.pro/support/feedback-issues)
- [よくある質問](https://docs.newapi.pro/support/faq)
## 🌟 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)

View File

@@ -1,5 +1,5 @@
<p align="right">
<strong>中文</strong> | <a href="./README.en.md">English</a>
<strong>中文</strong> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a> | <a href="./README.ja.md">日本語</a>
</p>
<div align="center">
@@ -40,6 +40,28 @@
> - 使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。
> - 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。
<h2>🤝 我们信任的合作伙伴</h2>
<p id="premium-sponsors">&nbsp;</p>
<p align="center"><strong>排名不分先后</strong></p>
<p align="center">
<a href="https://www.cherry-ai.com/" target=_blank><img
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
/></a>
<a href="https://bda.pku.edu.cn/" target=_blank><img
src="./docs/images/pku.png" alt="北京大学" height="120"
/></a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
src="./docs/images/ucloud.png" alt="UCloud 优刻得" height="120"
/></a>
<a href="https://www.aliyun.com/" target=_blank><img
src="./docs/images/aliyun.png" alt="阿里云" height="120"
/></a>
<a href="https://io.net/" target=_blank><img
src="./docs/images/io-net.png" alt="IO.NET" height="120"
/></a>
</p>
<p>&nbsp;</p>
## 📚 文档
详细文档请访问我们的官方Wiki[https://docs.newapi.pro/](https://docs.newapi.pro/)
@@ -53,7 +75,7 @@ New API提供了丰富的功能详细特性请参考[特性说明](https://do
1. 🎨 全新的UI界面
2. 🌍 多语言支持
3. 💰 支持在线充值功能(易支付)
3. 💰 支持在线充值功能当前支持易支付和Stripe
4. 🔍 支持用key查询使用额度配合[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)
5. 🔄 兼容原版One API的数据库
6. 💵 支持模型按次数收费
@@ -63,18 +85,23 @@ New API提供了丰富的功能详细特性请参考[特性说明](https://do
10. 🤖 支持更多授权登陆方式LinuxDO,Telegram、OIDC
11. 🔄 支持Rerank模型Cohere和Jina[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
12. ⚡ 支持OpenAI Realtime API包括Azure渠道[接口文档](https://docs.newapi.pro/api/openai-realtime)
13. ⚡ 支持Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
14. 支持使用路由/chat2link进入聊天界面
15. 🧠 支持通过模型名称后缀设置 reasoning effort
13. ⚡ 支持 **OpenAI Responses** 格式,[接口文档](https://docs.newapi.pro/api/openai-responses)
14. ⚡ 支持 **Claude Messages** 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
15. 支持 **Google Gemini** 格式,[接口文档](https://docs.newapi.pro/api/google-gemini-chat/)
16. 🧠 支持通过模型名称后缀设置 reasoning effort
1. OpenAI o系列模型
- 添加后缀 `-high` 设置为 high reasoning effort (例如: `o3-mini-high`)
- 添加后缀 `-medium` 设置为 medium reasoning effort (例如: `o3-mini-medium`)
- 添加后缀 `-low` 设置为 low reasoning effort (例如: `o3-mini-low`)
2. Claude 思考模型
- 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`)
16. 🔄 思考转内容功能
17. 🔄 针对用户的模型限流功能
18. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费
17. 🔄 思考转内容功能
18. 🔄 针对用户的模型限流功能
19. 🔄 请求格式转换功能,支持以下三种格式转换
1. OpenAI Chat Completions => Claude Messages OpenAI格式调用Claude模型
2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型)
3. OpenAI Chat Completions => Gemini Chat OpenAI格式调用Gemini模型
20. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
1.`系统设置-运营设置` 中设置 `提示缓存倍率` 选项
2. 在渠道中设置 `提示缓存倍率`,范围 0-1例如设置为 0.5 表示缓存命中时按照 50% 计费
3. 支持的渠道:
@@ -93,25 +120,25 @@ New API提供了丰富的功能详细特性请参考[特性说明](https://do
4. 自定义渠道,支持填入完整调用地址
5. Rerank模型[Cohere](https://cohere.ai/)和[Jina](https://jina.ai/)[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
6. Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
7. Dify当前仅支持chatflow
7. Google Gemini格式[接口文档](https://docs.newapi.pro/api/google-gemini-chat/)
8. Dify当前仅支持chatflow
9. 更多接口请参考[接口文档](https://docs.newapi.pro/api)
## 环境变量配置
详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables)
- `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`
- `STREAMING_TIMEOUT`:流式回复超时时间,默认120秒
- `STREAMING_TIMEOUT`:流式回复超时时间,默认300秒
- `DIFY_DEBUG`Dify渠道是否输出工作流和节点信息默认 `true`
- `FORCE_STREAM_OPTION`是否覆盖客户端stream_options参数默认 `true`
- `GET_MEDIA_TOKEN`是否统计图片token默认 `true`
- `GET_MEDIA_TOKEN_NOT_STREAM`非流情况下是否统计图片token默认 `true`
- `UPDATE_TASK`是否更新异步任务Midjourney、Suno默认 `true`
- `COHERE_SAFETY_SETTING`Cohere模型安全设置可选值为 `NONE`, `CONTEXTUAL`, `STRICT`,默认 `NONE`
- `GEMINI_VISION_MAX_IMAGE_NUM`Gemini模型最大图片数量默认 `16`
- `MAX_FILE_DOWNLOAD_MB`: 最大文件下载大小单位MB默认 `20`
- `CRYPTO_SECRET`:加密密钥,用于加密数据库内容
- `CRYPTO_SECRET`:加密密钥,用于加密Redis数据库内容
- `AZURE_DEFAULT_API_VERSION`Azure渠道默认API版本默认 `2025-04-01-preview`
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:通知限制持续时间,默认 `10`分钟
- `NOTIFICATION_LIMIT_DURATION_MINUTE`邮件等通知限制持续时间,默认 `10`分钟
- `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2`
- `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false`
@@ -156,7 +183,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
```
## 渠道重试与缓存
渠道重试功能已经实现,可以在`设置->运营设置->通用设置`设置重试次数,**建议开启缓存**功能。
渠道重试功能已经实现,可以在`设置->运营设置->通用设置->失败重试次数`设置重试次数,**建议开启缓存**功能。
### 缓存设置方法
1. `REDIS_CONN_STRING`设置Redis作为缓存
@@ -166,16 +193,17 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
详细接口文档请参考[接口文档](https://docs.newapi.pro/api)
- [聊天接口Chat](https://docs.newapi.pro/api/openai-chat)
- [聊天接口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)
- [重排序接口Rerank](https://docs.newapi.pro/api/jinaai-rerank)
- [实时对话接口Realtime](https://docs.newapi.pro/api/openai-realtime)
- [Claude聊天接口messages](https://docs.newapi.pro/api/anthropic-chat)
- [Claude聊天接口](https://docs.newapi.pro/api/anthropic-chat)
- [Google Gemini聊天接口](https://docs.newapi.pro/api/google-gemini-chat)
## 相关项目
- [One API](https://github.com/songquanpeng/one-api):原版项目
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy)Midjourney接口支持
- [chatnio](https://github.com/Deeptrain-Community/chatnio)下一代AI一站式B/C端解决方案
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)用key查询使用额度
其他基于New API的项目
@@ -188,24 +216,6 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
- [反馈问题](https://docs.newapi.pro/support/feedback-issues)
- [常见问题](https://docs.newapi.pro/support/faq)
## 🤝 我们信任的合作伙伴
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank"><img
src="./docs/images/cherry-studio.svg" alt="Cherry Studio" height="58"
/></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://bda.pku.edu.cn/" target="_blank"><img
src="./docs/images/pku.png" alt="北京大学" height="58"
/></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank"><img
src="./docs/images/ucloud.svg" alt="UCloud 优刻得" height="58"
/></a>
</p>
<p align="center"><em>排名不分先后</em></p>
## 🌟 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)

View File

@@ -1,6 +1,6 @@
package common
import "one-api/constant"
import "github.com/QuantumNous/new-api/constant"
func ChannelType2APIType(channelType int) (int, bool) {
apiType := -1
@@ -67,6 +67,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeJimeng
case constant.ChannelTypeMoonshot:
apiType = constant.APITypeMoonshot
case constant.ChannelTypeSubmodel:
apiType = constant.APITypeSubmodel
}
if apiType == -1 {
return constant.APITypeOpenAI, false

View File

@@ -19,6 +19,7 @@ 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
@@ -83,6 +84,7 @@ var GitHubClientId = ""
var GitHubClientSecret = ""
var LinuxDOClientId = ""
var LinuxDOClientSecret = ""
var LinuxDOMinimumTrustLevel = 0
var WeChatServerAddress = ""
var WeChatServerToken = ""

19
common/copy.go Normal file
View File

@@ -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
}

View File

@@ -4,6 +4,7 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"golang.org/x/crypto/bcrypt"
)

View File

@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
var UsingMySQL = false
var UsingClickHouse = false
var SQLitePath = "one-api.db?_busy_timeout=5000"
var SQLitePath = "one-api.db?_busy_timeout=30000"

View File

@@ -2,9 +2,10 @@ package common
import (
"embed"
"github.com/gin-contrib/static"
"io/fs"
"net/http"
"github.com/gin-contrib/static"
)
// Credit: https://github.com/gin-contrib/static/issues/19

View File

@@ -1,6 +1,6 @@
package common
import "one-api/constant"
import "github.com/QuantumNous/new-api/constant"
// EndpointInfo 描述单个端点的默认请求信息
// path: 上游路径
@@ -11,22 +11,23 @@ import "one-api/constant"
// 例如:{"path":"/v1/chat/completions","method":"POST"}
type EndpointInfo struct {
Path string `json:"path"`
Method string `json:"method"`
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.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
info, ok := defaultEndpointInfoMap[et]
return info, ok
}

View File

@@ -1,6 +1,6 @@
package common
import "one-api/constant"
import "github.com/QuantumNous/new-api/constant"
// GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点)
func GetEndpointTypesByChannelType(channelType int, modelName string) []constant.EndpointType {

View File

@@ -2,12 +2,15 @@ package common
import (
"bytes"
"github.com/gin-gonic/gin"
"io"
"mime/multipart"
"net/http"
"one-api/constant"
"strings"
"time"
"github.com/QuantumNous/new-api/constant"
"github.com/gin-gonic/gin"
)
const KeyRequestBody = "key_request_body"
@@ -112,3 +115,26 @@ func ApiSuccess(c *gin.Context, data any) {
"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 := ""
if idx := strings.Index(contentType, "boundary="); idx != -1 {
boundary = contentType[idx+9:]
}
reader := multipart.NewReader(bytes.NewReader(requestBody), boundary)
form, err := reader.ReadForm(32 << 20) // 32 MB max memory
if err != nil {
return nil, err
}
// Reset request body
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
return form, nil
}

View File

@@ -3,8 +3,9 @@ package common
import (
"context"
"fmt"
"github.com/bytedance/gopkg/util/gopool"
"math"
"github.com/bytedance/gopkg/util/gopool"
)
var relayGoPool gopool.Pool

View File

@@ -4,11 +4,12 @@ import (
"flag"
"fmt"
"log"
"one-api/constant"
"os"
"path/filepath"
"strconv"
"time"
"github.com/QuantumNous/new-api/constant"
)
var (
@@ -19,10 +20,10 @@ var (
)
func printHelp() {
fmt.Println("New API " + Version + " - All in one API service for OpenAI API.")
fmt.Println("Copyright (C) 2023 JustSong. All rights reserved.")
fmt.Println("GitHub: https://github.com/songquanpeng/one-api")
fmt.Println("Usage: one-api [--port <port>] [--log-dir <log directory>] [--version] [--help]")
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 <port>] [--log-dir <log directory>] [--version] [--help]")
}
func InitEnv() {
@@ -101,7 +102,7 @@ func InitEnv() {
}
func initConstantEnv() {
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 120)
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
// ForceStreamOption 覆盖请求参数强制返回usage信息

22
common/ip.go Normal file
View File

@@ -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
}

View File

@@ -20,3 +20,25 @@ func DecodeJson(reader *bytes.Reader, v any) error {
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"
}
}

View File

@@ -4,9 +4,10 @@ import (
"context"
_ "embed"
"fmt"
"github.com/go-redis/redis/v8"
"one-api/common"
"sync"
"github.com/QuantumNous/new-api/common"
"github.com/go-redis/redis/v8"
)
//go:embed lua/rate_limit.lua

View File

@@ -2,10 +2,11 @@ package common
import (
"fmt"
"github.com/shirou/gopsutil/cpu"
"os"
"runtime/pprof"
"time"
"github.com/shirou/gopsutil/cpu"
)
// Monitor 定时监控cpu使用率超过阈值输出pprof文件

5
common/quota.go Normal file
View File

@@ -0,0 +1,5 @@
package common
func GetTrustQuota() int {
return int(10 * QuotaPerUnit)
}

327
common/ssrf_protection.go Normal file
View File

@@ -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)
}

View File

@@ -99,12 +99,75 @@ func GetJsonString(data any) string {
return string(b)
}
// MaskSensitiveInfo masks sensitive information like URLs, IPs in a string
// 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]*`)
@@ -119,32 +182,8 @@ func MaskSensitiveInfo(str string) string {
return urlStr
}
// Split host by dots
parts := strings.Split(host, ".")
if len(parts) < 2 {
// If less than 2 parts, just mask the whole host
return u.Scheme + "://***" + u.Path
}
// Keep the TLD (Top Level Domain) and mask the rest
var maskedHost string
if len(parts) == 2 {
// example.com -> ***.com
maskedHost = "***." + parts[len(parts)-1]
} else {
// Handle cases like sub.domain.co.uk or api.example.com
// Keep last 2 parts if they look like country code TLD (co.uk, com.cn, etc.)
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
maskedHost = "***." + secondLastPart + "." + lastPart
} else {
// Regular TLD like .com, .org
maskedHost = "***." + lastPart
}
}
// Mask host with unified logic
maskedHost := maskHostForURL(host)
result := u.Scheme + "://" + maskedHost
@@ -184,6 +223,12 @@ func MaskSensitiveInfo(str string) string {
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, "***.***.***.***")

55
common/sys_log.go Normal file
View File

@@ -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")
}

View File

@@ -68,6 +68,78 @@ func GetIp() (ip string) {
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
@@ -123,8 +195,16 @@ func Interface2String(inter interface{}) string {
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 "Not Implemented"
return fmt.Sprintf("%v", inter)
}
func UnescapeHTML(x string) interface{} {
@@ -257,32 +337,32 @@ func GetAudioDuration(ctx context.Context, filename string, ext string) (float64
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration")
}
durationStr := string(bytes.TrimSpace(output))
if durationStr == "N/A" {
// Create a temporary output file name
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
if err != nil {
return 0, errors.Wrap(err, "failed to create temporary file")
}
tmpName := tmpFp.Name()
// Close immediately so ffmpeg can open the file on Windows.
_ = tmpFp.Close()
defer os.Remove(tmpName)
durationStr := string(bytes.TrimSpace(output))
if durationStr == "N/A" {
// Create a temporary output file name
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
if err != nil {
return 0, errors.Wrap(err, "failed to create temporary file")
}
tmpName := tmpFp.Name()
// Close immediately so ffmpeg can open the file on Windows.
_ = tmpFp.Close()
defer os.Remove(tmpName)
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
if err := ffmpegCmd.Run(); err != nil {
return 0, errors.Wrap(err, "failed to run ffmpeg")
}
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
if err := ffmpegCmd.Run(); err != nil {
return 0, errors.Wrap(err, "failed to run ffmpeg")
}
// Recalculate the duration of the new file
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
output, err := c.Output()
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
}
durationStr = string(bytes.TrimSpace(output))
}
// Recalculate the duration of the new file
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
output, err := c.Output()
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
}
durationStr = string(bytes.TrimSpace(output))
}
return strconv.ParseFloat(durationStr, 64)
}

View File

@@ -1,10 +1,11 @@
package common
import (
"github.com/google/uuid"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
type verificationValue struct {

161
config/plugin_config.go Normal file
View File

@@ -0,0 +1,161 @@
package config
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/core/interfaces"
"gopkg.in/yaml.v3"
)
// PluginConfig 插件配置结构
type PluginConfig struct {
Channels map[string]interfaces.ChannelConfig `yaml:"channels"`
Middlewares []interfaces.MiddlewareConfig `yaml:"middlewares"`
Hooks HooksConfig `yaml:"hooks"`
}
// HooksConfig Hook配置
type HooksConfig struct {
Relay []interfaces.HookConfig `yaml:"relay"`
}
var (
// 全局配置实例
globalPluginConfig *PluginConfig
)
// LoadPluginConfig 加载插件配置
func LoadPluginConfig(configPath string) (*PluginConfig, error) {
// 如果没有指定配置文件路径,使用默认路径
if configPath == "" {
configPath = "config/plugins.yaml"
}
// 检查文件是否存在
if _, err := os.Stat(configPath); os.IsNotExist(err) {
common.SysLog(fmt.Sprintf("Plugin config file not found: %s, using default configuration", configPath))
return getDefaultConfig(), nil
}
// 读取配置文件
data, err := ioutil.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read plugin config: %w", err)
}
// 解析YAML
var config PluginConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse plugin config: %w", err)
}
// 环境变量替换
expandEnvVars(&config)
common.SysLog(fmt.Sprintf("Loaded plugin config from: %s", configPath))
return &config, nil
}
// getDefaultConfig 返回默认配置
func getDefaultConfig() *PluginConfig {
return &PluginConfig{
Channels: make(map[string]interfaces.ChannelConfig),
Middlewares: make([]interfaces.MiddlewareConfig, 0),
Hooks: HooksConfig{
Relay: make([]interfaces.HookConfig, 0),
},
}
}
// expandEnvVars 展开环境变量
func expandEnvVars(config *PluginConfig) {
// 展开Hook配置中的环境变量
for i := range config.Hooks.Relay {
for key, value := range config.Hooks.Relay[i].Config {
if strValue, ok := value.(string); ok {
config.Hooks.Relay[i].Config[key] = os.ExpandEnv(strValue)
}
}
}
// 展开Middleware配置中的环境变量
for i := range config.Middlewares {
for key, value := range config.Middlewares[i].Config {
if strValue, ok := value.(string); ok {
config.Middlewares[i].Config[key] = os.ExpandEnv(strValue)
}
}
}
}
// GetGlobalPluginConfig 获取全局配置
func GetGlobalPluginConfig() *PluginConfig {
if globalPluginConfig == nil {
configPath := os.Getenv("PLUGIN_CONFIG_PATH")
if configPath == "" {
configPath = "config/plugins.yaml"
}
config, err := LoadPluginConfig(configPath)
if err != nil {
common.SysError(fmt.Sprintf("Failed to load plugin config: %v", err))
config = getDefaultConfig()
}
globalPluginConfig = config
}
return globalPluginConfig
}
// SavePluginConfig 保存插件配置
func SavePluginConfig(config *PluginConfig, configPath string) error {
if configPath == "" {
configPath = "config/plugins.yaml"
}
// 确保目录存在
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
// 序列化为YAML
data, err := yaml.Marshal(config)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// 写入文件
if err := ioutil.WriteFile(configPath, data, 0644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
common.SysLog(fmt.Sprintf("Saved plugin config to: %s", configPath))
return nil
}
// ReloadPluginConfig 重新加载配置
func ReloadPluginConfig() error {
configPath := os.Getenv("PLUGIN_CONFIG_PATH")
if configPath == "" {
configPath = "config/plugins.yaml"
}
config, err := LoadPluginConfig(configPath)
if err != nil {
return err
}
globalPluginConfig = config
common.SysLog("Plugin config reloaded")
return nil
}

52
config/plugins.yaml Normal file
View File

@@ -0,0 +1,52 @@
# New-API 插件配置
# 此文件用于配置所有插件的启用状态和参数
# Channel插件配置
channels:
openai:
enabled: true
priority: 100
claude:
enabled: true
priority: 90
gemini:
enabled: true
priority: 85
# Middleware插件配置
middlewares:
- name: auth
enabled: true
priority: 100
- name: ratelimit
enabled: true
priority: 90
config:
default_rate: 60
# Hook插件配置
hooks:
# Relay层Hook
relay:
# 联网搜索插件
- name: web_search
enabled: false # 默认禁用需要配置API key后启用
priority: 50
config:
provider: google
api_key: ${WEB_SEARCH_API_KEY} # 从环境变量读取
# 内容过滤插件
- name: content_filter
enabled: false # 默认禁用,需要配置后启用
priority: 100 # 高优先级,最后执行
config:
filter_nsfw: true
filter_political: false
sensitive_words:
- "敏感词1"
- "敏感词2"

View File

@@ -31,6 +31,7 @@ const (
APITypeXai
APITypeCoze
APITypeJimeng
APITypeMoonshot // this one is only for count, do not add any channel after this
APITypeDummy // this one is only for count, do not add any channel after this
APITypeMoonshot
APITypeSubmodel
APITypeDummy // this one is only for count, do not add any channel after this
)

View File

@@ -50,6 +50,9 @@ const (
ChannelTypeKling = 50
ChannelTypeJimeng = 51
ChannelTypeVidu = 52
ChannelTypeSubmodel = 53
ChannelTypeDoubaoVideo = 54
ChannelTypeSora = 55
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
@@ -108,4 +111,69 @@ var ChannelBaseURLs = []string{
"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
}
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",
}
func GetChannelTypeName(channelType int) string {
if name, ok := ChannelTypeNames[channelType]; ok {
return name
}
return "Unknown"
}

View File

@@ -3,6 +3,9 @@ package constant
type ContextKey string
const (
ContextKeyTokenCountMeta ContextKey = "token_count_meta"
ContextKeyPromptTokens ContextKey = "prompt_tokens"
ContextKeyOriginalModel ContextKey = "original_model"
ContextKeyRequestStartTime ContextKey = "request_start_time"
@@ -22,7 +25,9 @@ const (
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"

View File

@@ -9,6 +9,7 @@ const (
EndpointTypeGemini EndpointType = "gemini"
EndpointTypeJinaRerank EndpointType = "jina-rerank"
EndpointTypeImageGeneration EndpointType = "image-generation"
EndpointTypeEmbeddings EndpointType = "embeddings"
//EndpointTypeMidjourney EndpointType = "midjourney-proxy"
//EndpointTypeSuno EndpointType = "suno-proxy"
//EndpointTypeKling EndpointType = "kling"

View File

@@ -11,8 +11,10 @@ const (
SunoActionMusic = "MUSIC"
SunoActionLyrics = "LYRICS"
TaskActionGenerate = "generate"
TaskActionTextGenerate = "textGenerate"
TaskActionGenerate = "generate"
TaskActionTextGenerate = "textGenerate"
TaskActionFirstTailGenerate = "firstTailGenerate"
TaskActionReferenceGenerate = "referenceGenerate"
)
var SunoModel2Action = map[string]string{

View File

@@ -1,10 +1,11 @@
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"
"one-api/common"
"one-api/dto"
"one-api/model"
)
func GetSubscription(c *gin.Context) {
@@ -39,8 +40,18 @@ func GetSubscription(c *gin.Context) {
}
quota := remainQuota + usedQuota
amount := float64(quota)
if common.DisplayInCurrencyEnabled {
amount /= common.QuotaPerUnit
// 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
@@ -80,8 +91,13 @@ func GetUsage(c *gin.Context) {
return
}
amount := float64(quota)
if common.DisplayInCurrencyEnabled {
amount /= common.QuotaPerUnit
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",

View File

@@ -6,15 +6,16 @@ import (
"fmt"
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/model"
"one-api/service"
"one-api/setting"
"one-api/types"
"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"
@@ -127,6 +128,14 @@ func GetAuthHeader(token string) http.Header {
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 {
@@ -135,7 +144,11 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He
for k := range headers {
req.Header.Add(k, headers.Get(k))
}
res, err := service.GetHttpClient().Do(req)
client, err := service.NewProxyHttpClient(channel.GetSetting().Proxy)
if err != nil {
return nil, err
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
@@ -338,7 +351,7 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
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(setting.Price)).InexactFloat64()
availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(operation_setting.Price)).InexactFloat64()
channel.UpdateBalance(availableBalanceUsd)
return availableBalanceUsd, nil
}

View File

@@ -10,23 +10,26 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/middleware"
"one-api/model"
"one-api/relay"
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/relay/helper"
"one-api/service"
"one-api/types"
"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"
)
@@ -37,56 +40,63 @@ type testResult struct {
newAPIError *types.NewAPIError
}
func testChannel(channel *model.Channel, testModel string) testResult {
func testChannel(channel *model.Channel, testModel string, endpointType string) testResult {
tik := time.Now()
if channel.Type == constant.ChannelTypeMidjourney {
return testResult{
localErr: errors.New("midjourney channel test is not supported"),
newAPIError: nil,
}
var unsupportedTestChannelTypes = []int{
constant.ChannelTypeMidjourney,
constant.ChannelTypeMidjourneyPlus,
constant.ChannelTypeSunoAPI,
constant.ChannelTypeKling,
constant.ChannelTypeJimeng,
constant.ChannelTypeDoubaoVideo,
constant.ChannelTypeVidu,
}
if channel.Type == constant.ChannelTypeMidjourneyPlus {
if lo.Contains(unsupportedTestChannelTypes, channel.Type) {
channelTypeName := constant.GetChannelTypeName(channel.Type)
return testResult{
localErr: errors.New("midjourney plus channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeSunoAPI {
return testResult{
localErr: errors.New("suno channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeKling {
return testResult{
localErr: errors.New("kling channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeJimeng {
return testResult{
localErr: errors.New("jimeng channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeVidu {
return testResult{
localErr: errors.New("vidu channel test is not supported"),
newAPIError: nil,
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"
// 先判断是否为 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" // 修改请求路径
// 如果指定了端点类型,使用指定的端点类
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{
@@ -96,18 +106,6 @@ func testChannel(channel *model.Channel, testModel string) testResult {
Header: make(http.Header),
}
if testModel == "" {
if channel.TestModel != nil && *channel.TestModel != "" {
testModel = *channel.TestModel
} else {
if len(channel.GetModels()) > 0 {
testModel = channel.GetModels()[0]
} else {
testModel = "gpt-4o-mini"
}
}
}
cache, err := model.GetUserCache(1)
if err != nil {
return testResult{
@@ -133,9 +131,66 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
}
info := relaycommon.GenRelayInfo(c)
// 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
}
}
err = helper.ModelMappedHelper(c, info, nil)
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,
@@ -143,7 +198,10 @@ func testChannel(channel *model.Channel, testModel string) testResult {
newAPIError: types.NewError(err, types.ErrorCodeChannelModelMappedError),
}
}
testModel = info.UpstreamModelName
// 更新请求中的模型名称
request.SetModelName(testModel)
apiType, _ := common.ChannelType2APIType(channel.Type)
adaptor := relay.GetAdaptor(apiType)
@@ -155,13 +213,12 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
}
request := buildTestRequest(testModel)
// 创建一个用于日志的 info 副本,移除 ApiKey
logInfo := *info
logInfo.ApiKey = ""
common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, logInfo))
//// 创建一个用于日志的 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, int(request.GetMaxTokens()))
priceData, err := helper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta())
if err != nil {
return testResult{
context: c,
@@ -174,17 +231,62 @@ func testChannel(channel *model.Channel, testModel string) testResult {
var convertedRequest any
// 根据 RelayMode 选择正确的转换函数
if info.RelayMode == relayconstant.RelayModeEmbeddings {
// 创建一个 EmbeddingRequest
embeddingRequest := dto.EmbeddingRequest{
Input: request.Input,
Model: request.Model,
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),
}
}
// 调用专门用于 Embedding 的转换函数
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest)
} else {
// 对其他所有请求类型(如 Chat保持原有逻辑
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)
}
if err != nil {
@@ -216,7 +318,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
if resp != nil {
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
err := service.RelayErrorHandler(httpResp, true)
err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
return testResult{
context: c,
localErr: err,
@@ -287,22 +389,82 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
}
func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
testRequest := &dto.GeneralOpenAIRequest{
Model: "", // this will be set later
Stream: false,
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") || // 其他 embedding 模型
strings.HasPrefix(model, "m3e") || // m3e 系列模型
if strings.Contains(strings.ToLower(model), "embedding") ||
strings.HasPrefix(model, "m3e") ||
strings.Contains(model, "bge-") {
testRequest.Model = model
// Embedding 请求
testRequest.Input = []any{"hello world"} // 修改为any因为dto/openai_request.go 的ParseInput方法无法处理[]string类型
return testRequest
// 返回 EmbeddingRequest
return &dto.EmbeddingRequest{
Model: model,
Input: []any{"hello world"},
}
}
// 并非Embedding 模型
// 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") {
@@ -315,12 +477,6 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
testRequest.MaxTokens = 10
}
testMessage := dto.Message{
Role: "user",
Content: "hi",
}
testRequest.Model = model
testRequest.Messages = append(testRequest.Messages, testMessage)
return testRequest
}
@@ -344,8 +500,9 @@ func TestChannel(c *gin.Context) {
// }
//}()
testModel := c.Query("model")
endpointType := c.Query("endpoint_type")
tik := time.Now()
result := testChannel(channel, testModel)
result := testChannel(channel, testModel, endpointType)
if result.localErr != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -371,7 +528,6 @@ func TestChannel(c *gin.Context) {
"message": "",
"time": consumedTime,
})
return
}
var testAllChannelsLock sync.Mutex
@@ -405,7 +561,7 @@ func testAllChannels(notify bool) error {
for _, channel := range channels {
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
tik := time.Now()
result := testChannel(channel, "")
result := testChannel(channel, "", "")
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
@@ -419,7 +575,7 @@ func testAllChannels(notify bool) error {
// 当错误检查通过,才检查响应时间
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
if milliseconds > disableThreshold {
err := errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0))
err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
shouldBanChannel = true
}
@@ -427,7 +583,7 @@ func testAllChannels(notify bool) error {
// disable channel
if isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
go processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
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
@@ -456,18 +612,28 @@ func TestAllChannels(c *gin.Context) {
"success": true,
"message": "",
})
return
}
func AutomaticallyTestChannels(frequency int) {
if frequency <= 0 {
common.SysLog("CHANNEL_TEST_FREQUENCY is not set or invalid, skipping automatic channel test")
return
}
for {
time.Sleep(time.Duration(frequency) * time.Minute)
common.SysLog("testing all channels")
_ = testAllChannels(false)
common.SysLog("channel test finished")
}
var autoTestChannelsOnce sync.Once
func AutomaticallyTestChannels() {
autoTestChannelsOnce.Do(func() {
for {
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
time.Sleep(10 * time.Minute)
continue
}
for {
frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
time.Sleep(time.Duration(frequency) * time.Minute)
common.SysLog(fmt.Sprintf("automatically test channels with interval %d minutes", frequency))
common.SysLog("automatically testing all channels")
_ = testAllChannels(false)
common.SysLog("automatically channel test finished")
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
break
}
}
}
})
}

View File

@@ -4,12 +4,15 @@ import (
"encoding/json"
"fmt"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/model"
"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/service"
"github.com/gin-gonic/gin"
)
@@ -187,6 +190,8 @@ func FetchUpstreamModels(c *gin.Context) {
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)
default:
url = fmt.Sprintf("%s/v1/models", baseURL)
}
@@ -194,9 +199,10 @@ func FetchUpstreamModels(c *gin.Context) {
// 获取响应体 - 根据渠道类型决定是否添加 AuthHeader
var body []byte
key := strings.Split(channel.Key, "\n")[0]
if channel.Type == constant.ChannelTypeGemini {
body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key)) // Use AuthHeader since Gemini now forces it
} else {
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 {
@@ -380,6 +386,58 @@ func GetChannel(c *gin.Context) {
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
@@ -421,9 +479,10 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
}
type AddChannelRequest struct {
Mode string `json:"mode"`
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
Channel *model.Channel `json:"channel"`
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) {
@@ -481,7 +540,7 @@ func AddChannel(c *gin.Context) {
case "multi_to_single":
addChannelRequest.Channel.ChannelInfo.IsMultiKey = true
addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
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{
@@ -506,7 +565,7 @@ func AddChannel(c *gin.Context) {
}
keys = []string{addChannelRequest.Channel.Key}
case "batch":
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
// multi json
keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil {
@@ -536,6 +595,13 @@ func AddChannel(c *gin.Context) {
}
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)
@@ -543,6 +609,7 @@ func AddChannel(c *gin.Context) {
common.ApiError(c, err)
return
}
service.ResetProxyClientCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -761,7 +828,7 @@ func UpdateChannel(c *gin.Context) {
}
// 处理 Vertex AI 的特殊情况
if channel.Type == constant.ChannelTypeVertexAi {
if channel.Type == constant.ChannelTypeVertexAi && channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
// 尝试解析新密钥为JSON数组
if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") {
array, err := getVertexArrayKeys(channel.Key)
@@ -804,6 +871,7 @@ func UpdateChannel(c *gin.Context) {
return
}
model.InitChannelCache()
service.ResetProxyClientCache()
channel.Key = ""
clearChannelInfo(&channel.Channel)
c.JSON(http.StatusOK, gin.H{
@@ -1013,8 +1081,8 @@ func CopyChannel(c *gin.Context) {
// 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_disabled_keys", "get_key_status"
KeyIndex *int `json:"key_index,omitempty"` // for disable_key and enable_key actions
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
@@ -1342,6 +1410,86 @@ func ManageMultiKeys(c *gin.Context) {
})
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

View File

@@ -3,101 +3,103 @@
package controller
import (
"encoding/json"
"net/http"
"one-api/common"
"one-api/model"
"github.com/gin-gonic/gin"
"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
}
// 读取全部 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", "")
}
// 处理 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{})
// 删除旧键记录
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"})
}
// 重新加载 OptionMap
model.InitOptionMap()
common.SysLog("console setting migrated")
c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"})
}

View File

@@ -6,11 +6,12 @@ import (
"errors"
"fmt"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)

View File

@@ -2,9 +2,10 @@ package controller
import (
"net/http"
"one-api/model"
"one-api/setting"
"one-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)

View File

@@ -7,12 +7,13 @@ import (
"fmt"
"net/http"
"net/url"
"one-api/common"
"one-api/model"
"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"
)
@@ -220,21 +221,29 @@ func LinuxdoOAuth(c *gin.Context) {
}
} else {
if common.RegisterEnabled {
user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1)
user.DisplayName = linuxdoUser.Name
user.Role = common.RoleCommonUser
user.Status = common.UserStatusEnabled
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))
}
affCode := session.Get("aff")
inviterId := 0
if affCode != nil {
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
}
if err := user.Insert(inviterId); err != nil {
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": err.Error(),
"message": "Linux DO 信任等级未达到管理员设置的最低信任等级",
})
return
}

View File

@@ -2,10 +2,11 @@ package controller
import (
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)

View File

@@ -7,13 +7,16 @@ import (
"fmt"
"io"
"net/http"
"one-api/common"
"one-api/dto"
"one-api/model"
"one-api/service"
"one-api/setting"
"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"
)
@@ -28,7 +31,7 @@ func UpdateMidjourneyTaskBulk() {
continue
}
common.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
taskChannelM := make(map[int][]string)
taskM := make(map[string]*model.Midjourney)
nullTaskIds := make([]int, 0)
@@ -47,9 +50,9 @@ func UpdateMidjourneyTaskBulk() {
"progress": "100%",
})
if err != nil {
common.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
logger.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
} else {
common.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
logger.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
}
}
if len(taskChannelM) == 0 {
@@ -57,20 +60,20 @@ func UpdateMidjourneyTaskBulk() {
}
for channelId, taskIds := range taskChannelM {
common.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
if len(taskIds) == 0 {
continue
}
midjourneyChannel, err := model.CacheGetChannel(channelId)
if err != nil {
common.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err))
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 {
common.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
logger.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
}
continue
}
@@ -81,7 +84,7 @@ func UpdateMidjourneyTaskBulk() {
})
req, err := http.NewRequest("POST", requestUrl, bytes.NewBuffer(body))
if err != nil {
common.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
logger.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
continue
}
// 设置超时时间
@@ -93,22 +96,22 @@ func UpdateMidjourneyTaskBulk() {
req.Header.Set("mj-api-secret", midjourneyChannel.Key)
resp, err := service.GetHttpClient().Do(req)
if err != nil {
common.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
logger.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
continue
}
if resp.StatusCode != http.StatusOK {
common.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
continue
}
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
common.LogError(ctx, fmt.Sprintf("Get Task parse body error: %v", err))
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 {
common.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
logger.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
continue
}
resp.Body.Close()
@@ -147,12 +150,12 @@ func UpdateMidjourneyTaskBulk() {
}
// 映射 VideoUrl
task.VideoUrl = responseItem.VideoUrl
// 映射 VideoUrls - 将数组序列化为 JSON 字符串
if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 {
videoUrlsStr, err := json.Marshal(responseItem.VideoUrls)
if err != nil {
common.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
task.VideoUrls = "[]" // 失败时设置为空数组
} else {
task.VideoUrls = string(videoUrlsStr)
@@ -160,10 +163,10 @@ func UpdateMidjourneyTaskBulk() {
} else {
task.VideoUrls = "" // 空值时清空字段
}
shouldReturnQuota := false
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
common.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
logger.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
task.Progress = "100%"
if task.Quota != 0 {
shouldReturnQuota = true
@@ -171,14 +174,14 @@ func UpdateMidjourneyTaskBulk() {
}
err = task.Update()
if err != nil {
common.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
} else {
if shouldReturnQuota {
err = model.IncreaseUserQuota(task.UserId, task.Quota, false)
if err != nil {
common.LogError(ctx, "fail to increase user quota: "+err.Error())
logger.LogError(ctx, "fail to increase user quota: "+err.Error())
}
logContent := fmt.Sprintf("构图失败 %s补偿 %s", task.MjId, common.LogQuota(task.Quota))
logContent := fmt.Sprintf("构图失败 %s补偿 %s", task.MjId, logger.LogQuota(task.Quota))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
}
@@ -258,7 +261,7 @@ func GetAllMidjourney(c *gin.Context) {
if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
items[i] = midjourney
}
}
@@ -283,7 +286,7 @@ func GetUserMidjourney(c *gin.Context) {
if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
items[i] = midjourney
}
}

View File

@@ -4,16 +4,17 @@ import (
"encoding/json"
"fmt"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/middleware"
"one-api/model"
"one-api/setting"
"one-api/setting/console_setting"
"one-api/setting/operation_setting"
"one-api/setting/system_setting"
"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"
)
@@ -39,48 +40,54 @@ func TestStatus(c *gin.Context) {
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,
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
"linuxdo_client_id": common.LinuxDOClientId,
"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": setting.ServerAddress,
"price": setting.Price,
"stripe_unit_price": setting.StripeUnitPrice,
"min_topup": setting.MinTopUp,
"stripe_min_topup": setting.StripeMinTopUp,
"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": common.DisplayInCurrencyEnabled,
"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,
"enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
"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,
"pay_methods": setting.PayMethods,
"usd_exchange_rate": setting.USDExchangeRate,
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"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,
@@ -88,10 +95,23 @@ func GetStatus(c *gin.Context) {
"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 != "",
}
// 根据启用状态注入可选内容
@@ -135,6 +155,24 @@ func GetAbout(c *gin.Context) {
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()
@@ -246,7 +284,7 @@ func SendPasswordResetEmail(c *gin.Context) {
}
code := common.GenerateVerificationCode(0)
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", setting.ServerAddress, email, code)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+

View File

@@ -1,27 +1,28 @@
package controller
import (
"net/http"
"one-api/model"
"net/http"
"github.com/gin-gonic/gin"
"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
}
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,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": missing,
})
}

View File

@@ -2,20 +2,22 @@ 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/setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/model"
"one-api/relay"
"one-api/relay/channel/ai360"
"one-api/relay/channel/lingyiwanwu"
"one-api/relay/channel/minimax"
"one-api/relay/channel/moonshot"
relaycommon "one-api/relay/common"
"one-api/setting"
)
// https://platform.openai.com/docs/api-reference/models/list
@@ -92,7 +94,9 @@ func init() {
if !success || apiType == constant.APITypeAIProxyLibrary {
continue
}
meta := &relaycommon.RelayInfo{ChannelType: i}
meta := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: i,
}}
adaptor := relay.GetAdaptor(apiType)
adaptor.Init(meta)
channelId2Models[i] = adaptor.GetModelList()
@@ -102,7 +106,7 @@ func init() {
})
}
func ListModels(c *gin.Context) {
func ListModels(c *gin.Context, modelType int) {
userOpenAiModels := make([]dto.OpenAIModels, 0)
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
@@ -171,10 +175,42 @@ func ListModels(c *gin.Context) {
}
}
}
c.JSON(200, gin.H{
"success": true,
"data": userOpenAiModels,
})
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) {
@@ -198,10 +234,20 @@ func EnabledListModels(c *gin.Context) {
})
}
func RetrieveModel(c *gin.Context) {
func RetrieveModel(c *gin.Context, modelType int) {
modelId := c.Param("model")
if aiModel, ok := openAIModelsMap[modelId]; ok {
c.JSON(200, aiModel)
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),

View File

@@ -1,178 +1,330 @@
package controller
import (
"encoding/json"
"strconv"
"encoding/json"
"sort"
"strconv"
"strings"
"one-api/common"
"one-api/model"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"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
}
// 填充附加字段
for _, m := range modelsMeta {
fillModelExtra(m)
}
var total int64
model.DB.Model(&model.Model{}).Count(&total)
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()
// 统计供应商计数(全部数据,不受分页影响)
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,
})
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)
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
}
for _, m := range modelsMeta {
fillModelExtra(m)
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(modelsMeta)
common.ApiSuccess(c, pageInfo)
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
}
fillModelExtra(&m)
common.ApiSuccess(c, &m)
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
}
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)
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"
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
}
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 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)
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)
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)
}
// 辅助函数:填充 Endpoints 和 BoundChannels 和 EnableGroups
func fillModelExtra(m *model.Model) {
if m.Endpoints == "" {
eps := model.GetModelSupportEndpointTypes(m.ModelName)
if b, err := json.Marshal(eps); err == nil {
m.Endpoints = string(b)
}
}
if channels, err := model.GetBoundChannels(m.ModelName); err == nil {
m.BoundChannels = channels
}
// 填充启用分组
m.EnableGroups = model.GetModelEnableGroups(m.ModelName)
// 填充计费类型
m.QuotaType = model.GetModelQuotaType(m.ModelName)
// 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)
}
}

604
controller/model_sync.go Normal file
View File

@@ -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<<attempt)
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
time.Sleep(sleep + jitter)
continue
}
func() {
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// read body into buffer for caching and flexible decode
limited := io.LimitReader(resp.Body, maxBytes)
buf, err := io.ReadAll(limited)
if err != nil {
lastErr = err
return
}
// cache body and ETag
cacheMutex.Lock()
if et := resp.Header.Get("ETag"); et != "" {
etagCache[url] = et
}
bodyCache[url] = buf
cacheMutex.Unlock()
// Try decode as envelope first
if err := json.Unmarshal(buf, out); err != nil {
// Try decode as pure array
var arr []T
if err2 := json.Unmarshal(buf, &arr); err2 != nil {
lastErr = err
return
}
out.Success = true
out.Data = arr
out.Message = ""
} else {
if !out.Success && len(out.Data) == 0 && out.Message == "" {
out.Success = true
}
}
lastErr = nil
case http.StatusNotModified:
// use cache
cacheMutex.RLock()
buf := bodyCache[url]
cacheMutex.RUnlock()
if len(buf) == 0 {
lastErr = errors.New("cache miss for 304 response")
return
}
if err := json.Unmarshal(buf, out); err != nil {
var arr []T
if err2 := json.Unmarshal(buf, &arr); err2 != nil {
lastErr = err
return
}
out.Success = true
out.Data = arr
out.Message = ""
} else {
if !out.Success && len(out.Data) == 0 && out.Message == "" {
out.Success = true
}
}
lastErr = nil
default:
lastErr = errors.New(resp.Status)
}
}()
if lastErr == nil {
return nil
}
sleep := baseDelay * time.Duration(1<<attempt)
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
time.Sleep(sleep + jitter)
}
return lastErr
}
func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, vendorIDCache map[string]int, createdVendors *int) int {
if vendorName == "" {
return 0
}
if id, ok := vendorIDCache[vendorName]; ok {
return id
}
var existing model.Vendor
if err := model.DB.Where("name = ?", vendorName).First(&existing).Error; err == nil {
vendorIDCache[vendorName] = existing.Id
return existing.Id
}
uv := vendorByName[vendorName]
v := &model.Vendor{
Name: vendorName,
Description: uv.Description,
Icon: coalesce(uv.Icon, ""),
Status: chooseStatus(uv.Status, 1),
}
if err := v.Insert(); err == nil {
*createdVendors++
vendorIDCache[vendorName] = v.Id
return v.Id
}
vendorIDCache[vendorName] = 0
return 0
}
// SyncUpstreamModels 同步上游模型与供应商,仅对「未配置模型」生效
func SyncUpstreamModels(c *gin.Context) {
var req syncRequest
// 允许空体
_ = c.ShouldBindJSON(&req)
// 1) 获取未配置模型列表
missing, err := model.GetMissingModels()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
if len(missing) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"created_models": 0,
"created_vendors": 0,
"skipped_models": []string{},
}})
return
}
// 2) 拉取上游 vendors 与 models
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
defer cancel()
modelsURL, vendorsURL := getUpstreamURLs(req.Locale)
var vendorsEnv upstreamEnvelope[upstreamVendor]
var modelsEnv upstreamEnvelope[upstreamModel]
var fetchErr error
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
// vendor 失败不拦截
_ = 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": req.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)
for _, m := range modelsEnv.Data {
if m.ModelName != "" {
modelByName[m.ModelName] = m
}
}
// 3) 执行同步:仅创建缺失模型;若上游缺失该模型则跳过
createdModels := 0
createdVendors := 0
updatedModels := 0
var skipped []string
var createdList []string
var updatedList []string
// 本地缓存vendorName -> 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,
},
},
})
}

View File

@@ -6,14 +6,14 @@ import (
"fmt"
"net/http"
"net/url"
"one-api/common"
"one-api/model"
"one-api/setting"
"one-api/setting/system_setting"
"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"
)
@@ -45,7 +45,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
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", setting.ServerAddress))
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 {
@@ -69,7 +69,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
}
if oidcResponse.AccessToken == "" {
common.SysError("OIDC 获取 Token 失败,请检查设置!")
common.SysLog("OIDC 获取 Token 失败,请检查设置!")
return nil, errors.New("OIDC 获取 Token 失败,请检查设置!")
}
@@ -85,7 +85,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
}
defer res2.Body.Close()
if res2.StatusCode != http.StatusOK {
common.SysError("OIDC 获取用户信息失败!请检查设置!")
common.SysLog("OIDC 获取用户信息失败!请检查设置!")
return nil, errors.New("OIDC 获取用户信息失败!请检查设置!")
}
@@ -95,7 +95,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
return nil, err
}
if oidcUser.OpenID == "" || oidcUser.Email == "" {
common.SysError("OIDC 获取用户信息为空!请检查设置!")
common.SysLog("OIDC 获取用户信息为空!请检查设置!")
return nil, errors.New("OIDC 获取用户信息为空!请检查设置!")
}
return &oidcUser, nil

View File

@@ -2,15 +2,17 @@ package controller
import (
"encoding/json"
"fmt"
"net/http"
"one-api/common"
"one-api/model"
"one-api/setting"
"one-api/setting/console_setting"
"one-api/setting/ratio_setting"
"one-api/setting/system_setting"
"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"
)
@@ -35,8 +37,13 @@ func GetOptions(c *gin.Context) {
return
}
type OptionUpdateRequest struct {
Key string `json:"key"`
Value any `json:"value"`
}
func UpdateOption(c *gin.Context) {
var option model.Option
var option OptionUpdateRequest
err := json.NewDecoder(c.Request.Body).Decode(&option)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
@@ -45,6 +52,16 @@ func UpdateOption(c *gin.Context) {
})
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 == "" {
@@ -104,7 +121,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "GroupRatio":
err = ratio_setting.CheckGroupRatio(option.Value)
err = ratio_setting.CheckGroupRatio(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -112,8 +129,35 @@ func UpdateOption(c *gin.Context) {
})
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)
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -122,7 +166,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.api_info":
err = console_setting.ValidateConsoleSettings(option.Value, "ApiInfo")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -131,7 +175,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.announcements":
err = console_setting.ValidateConsoleSettings(option.Value, "Announcements")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -140,7 +184,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.faq":
err = console_setting.ValidateConsoleSettings(option.Value, "FAQ")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -149,7 +193,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.uptime_kuma_groups":
err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -158,7 +202,7 @@ func UpdateOption(c *gin.Context) {
return
}
}
err = model.UpdateOption(option.Key, option.Value)
err = model.UpdateOption(option.Key, option.Value.(string))
if err != nil {
common.ApiError(c, err)
return

497
controller/passkey.go Normal file
View File

@@ -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
}

View File

@@ -3,13 +3,14 @@ package controller
import (
"errors"
"fmt"
"one-api/common"
"one-api/constant"
"one-api/middleware"
"one-api/model"
"one-api/types"
"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"
)
@@ -56,5 +57,5 @@ func Playground(c *gin.Context) {
//middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model)
common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now())
Relay(c)
Relay(c, types.RelayFormatOpenAI)
}

View File

@@ -1,90 +1,90 @@
package controller
import (
"strconv"
"strconv"
"one-api/common"
"one-api/model"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"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)
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
}
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)
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
}
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)
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)
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)
}

View File

@@ -1,9 +1,9 @@
package controller
import (
"one-api/model"
"one-api/setting"
"one-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
@@ -39,14 +39,14 @@ func GetPricing(c *gin.Context) {
}
c.JSON(200, gin.H{
"success": true,
"data": pricing,
"vendors": model.GetVendors(),
"group_ratio": groupRatio,
"usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
"auto_groups": setting.AutoGroups,
})
"success": true,
"data": pricing,
"vendors": model.GetVendors(),
"group_ratio": groupRatio,
"usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
"auto_groups": setting.AutoGroups,
})
}
func ResetModelRatio(c *gin.Context) {

View File

@@ -1,24 +1,25 @@
package controller
import (
"net/http"
"one-api/setting/ratio_setting"
"net/http"
"github.com/gin-gonic/gin"
"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
}
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(),
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": ratio_setting.GetExposedData(),
})
}

View File

@@ -1,474 +1,540 @@
package controller
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
"one-api/common"
"one-api/dto"
"one-api/model"
"one-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/logger"
"github.com/gin-gonic/gin"
"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
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"`
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
}
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
}
if req.Timeout <= 0 {
req.Timeout = defaultTimeoutSeconds
}
var upstreams []dto.UpstreamDTO
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 {
common.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(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
}
if len(upstreams) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"})
return
}
var wg sync.WaitGroup
ch := make(chan upstreamResult, len(upstreams))
var wg sync.WaitGroup
ch := make(chan upstreamResult, len(upstreams))
sem := make(chan struct{}, maxConcurrentFetches)
sem := make(chan struct{}, maxConcurrentFetches)
client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}}
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()
for _, chn := range upstreams {
wg.Add(1)
go func(chItem dto.UpstreamDTO) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
sem <- struct{}{}
defer func() { <-sem }()
endpoint := chItem.Endpoint
if endpoint == "" {
endpoint = defaultEndpoint
} else if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
fullURL := chItem.BaseURL + endpoint
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)
}
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()
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 {
common.LogWarn(c.Request.Context(), "build request failed: "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
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
}
resp, err := client.Do(httpReq)
if err != nil {
common.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
common.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status)
ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
return
}
// 兼容两种上游接口格式:
// type1: /api/ratio_config -> 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"`
}
// 简单重试:最多 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<<attempt)) * time.Millisecond)
}
if lastErr != nil {
logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+lastErr.Error())
ch <- upstreamResult{Name: uniqueName, Err: lastErr.Error()}
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status)
ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
return
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
common.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
// Content-Type 和响应体大小校验
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "application/json") {
logger.LogWarn(c.Request.Context(), "unexpected content-type from "+chItem.Name+": "+ct)
}
limited := io.LimitReader(resp.Body, maxRatioConfigBytes)
// 兼容两种上游接口格式:
// type1: /api/ratio_config -> 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 !body.Success {
ch <- upstreamResult{Name: uniqueName, Err: body.Message}
return
}
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
}
// 尝试按 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
}
}
if !body.Success {
ch <- upstreamResult{Name: uniqueName, Err: body.Message}
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 {
common.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"}
return
}
// 若 Data 为空,将继续按 type1 尝试解析(与多数静态 ratio_config 兼容)
modelRatioMap := make(map[string]float64)
completionRatioMap := make(map[string]float64)
modelPriceMap := make(map[string]float64)
// 尝试按 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
}
}
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
}
}
// 如果不是 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
}
converted := make(map[string]any)
modelRatioMap := make(map[string]float64)
completionRatioMap := make(map[string]float64)
modelPriceMap := make(map[string]float64)
if len(modelRatioMap) > 0 {
ratioAny := make(map[string]any, len(modelRatioMap))
for k, v := range modelRatioMap {
ratioAny[k] = v
}
converted["model_ratio"] = ratioAny
}
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
}
}
if len(completionRatioMap) > 0 {
compAny := make(map[string]any, len(completionRatioMap))
for k, v := range completionRatioMap {
compAny[k] = v
}
converted["completion_ratio"] = compAny
}
converted := make(map[string]any)
if len(modelPriceMap) > 0 {
priceAny := make(map[string]any, len(modelPriceMap))
for k, v := range modelPriceMap {
priceAny[k] = v
}
converted["model_price"] = priceAny
}
if len(modelRatioMap) > 0 {
ratioAny := make(map[string]any, len(modelRatioMap))
for k, v := range modelRatioMap {
ratioAny[k] = v
}
converted["model_ratio"] = ratioAny
}
ch <- upstreamResult{Name: uniqueName, Data: converted}
}(chn)
}
if len(completionRatioMap) > 0 {
compAny := make(map[string]any, len(completionRatioMap))
for k, v := range completionRatioMap {
compAny[k] = v
}
converted["completion_ratio"] = compAny
}
wg.Wait()
close(ch)
if len(modelPriceMap) > 0 {
priceAny := make(map[string]any, len(modelPriceMap))
for k, v := range modelPriceMap {
priceAny[k] = v
}
converted["model_price"] = priceAny
}
localData := ratio_setting.GetExposedData()
ch <- upstreamResult{Name: uniqueName, Data: converted}
}(chn)
}
var testResults []dto.TestResult
var successfulChannels []struct {
name string
data map[string]any
}
wg.Wait()
close(ch)
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})
}
}
localData := ratio_setting.GetExposedData()
differences := buildDifferences(localData, successfulChannels)
var testResults []dto.TestResult
var successfulChannels []struct {
name string
data map[string]any
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"differences": differences,
"test_results": testResults,
},
})
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
name string
data map[string]any
}) map[string]map[string]dto.DifferenceItem {
differences := make(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{}{}
}
}
}
}
allModels := make(map[string]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 _, 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 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
}
}
}
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{}{}
}
}
}
}
upstreamValues := make(map[string]interface{})
confidenceValues := make(map[string]bool)
hasUpstreamValue := false
hasDifference := false
confidenceMap := make(map[string]map[string]bool)
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 && localValue != val {
hasDifference = true
} else if 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]
}
// 预处理阶段检查pricing接口的可信度
for _, channel := range successfulChannels {
confidenceMap[channel.name] = make(map[string]bool)
shouldInclude := false
if localValue != nil {
if hasDifference {
shouldInclude = true
}
} else {
if hasUpstreamValue {
shouldInclude = true
}
}
modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any)
completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any)
if shouldInclude {
if differences[modelName] == nil {
differences[modelName] = make(map[string]dto.DifferenceItem)
}
differences[modelName][ratioType] = dto.DifferenceItem{
Current: localValue,
Upstreams: upstreamValues,
Confidence: confidenceValues,
}
}
}
}
if hasModelRatio && hasCompletionRatio {
// 遍历所有模型,检查是否满足不可信条件
for modelName := range allModels {
// 默认为可信
confidenceMap[channel.name][modelName] = true
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
}
}
}
}
// 检查是否满足不可信条件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, ratioMap := range differences {
for ratioType, item := range ratioMap {
for chName := range item.Upstreams {
if !channelHasDiff[chName] {
delete(item.Upstreams, chName)
delete(item.Confidence, chName)
}
}
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
}
}
}
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
}
}
upstreamValues := make(map[string]interface{})
confidenceValues := make(map[string]bool)
hasUpstreamValue := false
hasDifference := false
if len(ratioMap) == 0 {
delete(differences, modelName)
}
}
for _, channel := range successfulChannels {
var upstreamValue interface{} = nil
return differences
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
}
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,
})
}
}
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,
})
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": syncableChannels,
})
}
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,
})
}

View File

@@ -3,11 +3,12 @@ package controller
import (
"errors"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"unicode/utf8"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)

View File

@@ -2,122 +2,198 @@ package controller
import (
"bytes"
"errors"
"fmt"
"io"
"log"
"net/http"
"one-api/common"
"one-api/constant"
constant2 "one-api/constant"
"one-api/dto"
"one-api/middleware"
"one-api/model"
"one-api/relay"
relayconstant "one-api/relay/constant"
"one-api/relay/helper"
"one-api/service"
"one-api/types"
"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, relayMode int) *types.NewAPIError {
func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError {
var err *types.NewAPIError
switch relayMode {
switch info.RelayMode {
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
err = relay.ImageHelper(c)
err = relay.ImageHelper(c, info)
case relayconstant.RelayModeAudioSpeech:
fallthrough
case relayconstant.RelayModeAudioTranslation:
fallthrough
case relayconstant.RelayModeAudioTranscription:
err = relay.AudioHelper(c)
err = relay.AudioHelper(c, info)
case relayconstant.RelayModeRerank:
err = relay.RerankHelper(c, relayMode)
err = relay.RerankHelper(c, info)
case relayconstant.RelayModeEmbeddings:
err = relay.EmbeddingHelper(c)
err = relay.EmbeddingHelper(c, info)
case relayconstant.RelayModeResponses:
err = relay.ResponsesHelper(c)
case relayconstant.RelayModeGemini:
err = relay.GeminiHelper(c)
err = relay.ResponsesHelper(c, info)
default:
err = relay.TextHelper(c)
err = relay.TextHelper(c, info)
}
if constant2.ErrorLogEnabled && err != nil && 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{})
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)
}
return err
}
func Relay(c *gin.Context) {
relayMode := relayconstant.Path2RelayMode(c.Request.URL.Path)
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 := c.GetString("group")
originalModel := c.GetString("original_model")
var newAPIError *types.NewAPIError
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 {
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 {
common.LogError(c, err.Error())
logger.LogError(c, err.Error())
newAPIError = err
break
}
newAPIError = relayRequest(c, relayMode, channel)
addUsedChannel(c, channel.Id)
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
if newAPIError == nil {
return // 成功处理请求,直接返回
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)
}
go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
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)), "->"), "[]"))
common.LogInfo(c, retryLogStr)
}
if newAPIError != nil {
//if newAPIError.StatusCode == http.StatusTooManyRequests {
// common.LogError(c, fmt.Sprintf("origin 429 error: %s", newAPIError.Error()))
// newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试")
//}
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
c.JSON(newAPIError.StatusCode, gin.H{
"error": newAPIError.ToOpenAIError(),
})
logger.LogInfo(c, retryLogStr)
}
}
@@ -128,122 +204,6 @@ var upgrader = websocket.Upgrader{
},
}
func WssRelay(c *gin.Context) {
// 将 HTTP 连接升级为 WebSocket 连接
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
defer ws.Close()
if err != nil {
helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()).ToOpenAIError())
return
}
relayMode := relayconstant.Path2RelayMode(c.Request.URL.Path)
requestId := c.GetString(common.RequestIdKey)
group := c.GetString("group")
//wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01
originalModel := c.GetString("original_model")
var newAPIError *types.NewAPIError
for i := 0; i <= common.RetryTimes; i++ {
channel, err := getChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, err.Error())
newAPIError = err
break
}
newAPIError = wssRequest(c, ws, relayMode, channel)
if newAPIError == nil {
return // 成功处理请求,直接返回
}
go 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)), "->"), "[]"))
common.LogInfo(c, retryLogStr)
}
if newAPIError != nil {
//if newAPIError.StatusCode == http.StatusTooManyRequests {
// newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试")
//}
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
helper.WssError(c, ws, newAPIError.ToOpenAIError())
}
}
func RelayClaude(c *gin.Context) {
//relayMode := constant.Path2RelayMode(c.Request.URL.Path)
requestId := c.GetString(common.RequestIdKey)
group := c.GetString("group")
originalModel := c.GetString("original_model")
var newAPIError *types.NewAPIError
for i := 0; i <= common.RetryTimes; i++ {
channel, err := getChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, err.Error())
newAPIError = err
break
}
newAPIError = claudeRequest(c, channel)
if newAPIError == nil {
return // 成功处理请求,直接返回
}
go 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)), "->"), "[]"))
common.LogInfo(c, retryLogStr)
}
if newAPIError != nil {
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
c.JSON(newAPIError.StatusCode, gin.H{
"type": "error",
"error": newAPIError.ToClaudeError(),
})
}
}
func relayRequest(c *gin.Context, relayMode int, channel *model.Channel) *types.NewAPIError {
addUsedChannel(c, channel.Id)
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
return relayHandler(c, relayMode)
}
func wssRequest(c *gin.Context, ws *websocket.Conn, relayMode int, channel *model.Channel) *types.NewAPIError {
addUsedChannel(c, channel.Id)
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
return relay.WssHelper(c, ws)
}
func claudeRequest(c *gin.Context, channel *model.Channel) *types.NewAPIError {
addUsedChannel(c, channel.Id)
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
return relay.ClaudeHelper(c)
}
func addUsedChannel(c *gin.Context, channelId int) {
useChannel := c.GetStringSlice("use_channel")
useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
@@ -266,10 +226,10 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m
}
channel, selectGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
if err != nil {
return nil, types.NewError(errors.New(fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败retry: %s", selectGroup, originalModel, err.Error())), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
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(errors.New(fmt.Sprintf("分组 %s 下模型 %s 的可用渠道不存在数据库一致性已被破坏retry", selectGroup, originalModel)), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在数据库一致性已被破坏retry", selectGroup, originalModel), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel)
if newAPIError != nil {
@@ -308,10 +268,6 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
return true
}
if openaiErr.StatusCode == http.StatusBadRequest {
channelType := c.GetInt("channel_type")
if channelType == constant.ChannelTypeAnthropic {
return true
}
return false
}
if openaiErr.StatusCode == 408 {
@@ -325,44 +281,83 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
}
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
logger.LogError(c, fmt.Sprintf("relay 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
common.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
service.DisableChannel(channelError, err.Error())
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{})
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) {
relayMode := c.GetInt("relay_mode")
var err *dto.MidjourneyResponse
switch relayMode {
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:
err = relay.RelayMidjourneyNotify(c)
mjErr = relay.RelayMidjourneyNotify(c)
case relayconstant.RelayModeMidjourneyTaskFetch, relayconstant.RelayModeMidjourneyTaskFetchByCondition:
err = relay.RelayMidjourneyTask(c, relayMode)
mjErr = relay.RelayMidjourneyTask(c, relayInfo.RelayMode)
case relayconstant.RelayModeMidjourneyTaskImageSeed:
err = relay.RelayMidjourneyTaskImageSeed(c)
mjErr = relay.RelayMidjourneyTaskImageSeed(c)
case relayconstant.RelayModeSwapFace:
err = relay.RelaySwapFace(c)
mjErr = relay.RelaySwapFace(c, relayInfo)
default:
err = relay.RelayMidjourneySubmit(c, relayMode)
mjErr = relay.RelayMidjourneySubmit(c, relayInfo)
}
//err = relayMidjourneySubmit(c, relayMode)
log.Println(err)
if err != nil {
log.Println(mjErr)
if mjErr != nil {
statusCode := http.StatusBadRequest
if err.Code == 30 {
err.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
if mjErr.Code == 30 {
mjErr.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
statusCode = http.StatusTooManyRequests
}
c.JSON(statusCode, gin.H{
"description": fmt.Sprintf("%s %s", err.Description, err.Result),
"description": fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result),
"type": "upstream_error",
"code": err.Code,
"code": mjErr.Code,
})
channelId := c.GetInt("channel_id")
common.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", err.Description, err.Result)))
logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result)))
}
}
@@ -393,18 +388,21 @@ func RelayNotFound(c *gin.Context) {
func RelayTask(c *gin.Context) {
retryTimes := common.RetryTimes
channelId := c.GetInt("channel_id")
relayMode := c.GetInt("relay_mode")
group := c.GetString("group")
originalModel := c.GetString("original_model")
c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)})
taskErr := taskRelayHandler(c, relayMode)
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 {
common.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error()))
logger.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error()))
taskErr = service.TaskErrorWrapperLocal(newAPIError.Err, "get_channel_failed", http.StatusInternalServerError)
break
}
@@ -412,17 +410,17 @@ func RelayTask(c *gin.Context) {
useChannel := c.GetStringSlice("use_channel")
useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
c.Set("use_channel", useChannel)
common.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, i))
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, relayMode)
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)), "->"), "[]"))
common.LogInfo(c, retryLogStr)
logger.LogInfo(c, retryLogStr)
}
if taskErr != nil {
if taskErr.StatusCode == http.StatusTooManyRequests {
@@ -432,13 +430,13 @@ func RelayTask(c *gin.Context) {
}
}
func taskRelayHandler(c *gin.Context, relayMode int) *dto.TaskError {
func taskRelayHandler(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dto.TaskError {
var err *dto.TaskError
switch relayMode {
switch relayInfo.RelayMode {
case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeVideoFetchByID:
err = relay.RelayTaskFetch(c, relayMode)
err = relay.RelayTaskFetch(c, relayInfo.RelayMode)
default:
err = relay.RelayTaskSubmit(c, relayMode)
err = relay.RelayTaskSubmit(c, relayInfo)
}
return err
}

View File

@@ -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,
},
})
}

View File

@@ -1,12 +1,13 @@
package controller
import (
"github.com/gin-gonic/gin"
"one-api/common"
"one-api/constant"
"one-api/model"
"one-api/setting/operation_setting"
"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 {
@@ -53,7 +54,7 @@ func GetSetup(c *gin.Context) {
func PostSetup(c *gin.Context) {
// Check if setup is already completed
if constant.Setup {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "系统已经初始化完成",
})
@@ -66,7 +67,7 @@ func PostSetup(c *gin.Context) {
var req SetupRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "请求参数有误",
})
@@ -77,7 +78,7 @@ func PostSetup(c *gin.Context) {
if !rootExists {
// Validate username length: max 12 characters to align with model.User validation
if len(req.Username) > 12 {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "用户名长度不能超过12个字符",
})
@@ -85,7 +86,7 @@ func PostSetup(c *gin.Context) {
}
// Validate password
if req.Password != req.ConfirmPassword {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "两次输入的密码不一致",
})
@@ -93,7 +94,7 @@ func PostSetup(c *gin.Context) {
}
if len(req.Password) < 8 {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "密码长度至少为8个字符",
})
@@ -103,7 +104,7 @@ func PostSetup(c *gin.Context) {
// Create root user
hashedPassword, err := common.Password2Hash(req.Password)
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "系统错误: " + err.Error(),
})
@@ -120,7 +121,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&rootUser).Error
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "创建管理员账号失败: " + err.Error(),
})
@@ -135,7 +136,7 @@ func PostSetup(c *gin.Context) {
// Save operation modes to database for persistence
err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "保存自用模式设置失败: " + err.Error(),
})
@@ -144,7 +145,7 @@ func PostSetup(c *gin.Context) {
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "保存演示站点模式设置失败: " + err.Error(),
})
@@ -160,7 +161,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&setup).Error
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "系统初始化失败: " + err.Error(),
})

View File

@@ -7,15 +7,17 @@ import (
"fmt"
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/model"
"one-api/relay"
"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"
)
@@ -54,9 +56,9 @@ func UpdateTaskBulk() {
"progress": "100%",
})
if err != nil {
common.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err))
logger.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err))
} else {
common.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds))
logger.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds))
}
}
if len(taskChannelM) == 0 {
@@ -86,14 +88,14 @@ func UpdateSunoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM
for channelId, taskIds := range taskChannelM {
err := updateSunoTaskAll(ctx, channelId, taskIds, taskM)
if err != nil {
common.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %d", channelId, err.Error()))
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 {
common.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
if len(taskIds) == 0 {
return nil
}
@@ -106,7 +108,7 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
"progress": "100%",
})
if err != nil {
common.SysError(fmt.Sprintf("UpdateMidjourneyTask error2: %v", err))
common.SysLog(fmt.Sprintf("UpdateMidjourneyTask error2: %v", err))
}
return err
}
@@ -118,23 +120,23 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
"ids": taskIds,
})
if err != nil {
common.SysError(fmt.Sprintf("Get Task Do req error: %v", err))
common.SysLog(fmt.Sprintf("Get Task Do req error: %v", err))
return err
}
if resp.StatusCode != http.StatusOK {
common.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
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.SysError(fmt.Sprintf("Get Task parse body error: %v", err))
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 {
common.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
logger.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
return err
}
if !responseItems.IsSuccess() {
@@ -154,19 +156,19 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
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 {
common.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason)
logger.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason)
task.Progress = "100%"
//err = model.CacheUpdateUserQuota(task.UserId) ?
if err != nil {
common.LogError(ctx, "error update user quota cache: "+err.Error())
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 {
common.LogError(ctx, "fail to increase user quota: "+err.Error())
logger.LogError(ctx, "fail to increase user quota: "+err.Error())
}
logContent := fmt.Sprintf("异步任务执行失败 %s补偿 %s", task.TaskID, common.LogQuota(quota))
logContent := fmt.Sprintf("异步任务执行失败 %s补偿 %s", task.TaskID, logger.LogQuota(quota))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
}
@@ -178,7 +180,7 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
err = task.Update()
if err != nil {
common.SysError("UpdateMidjourneyTask task error: " + err.Error())
common.SysLog("UpdateMidjourneyTask task error: " + err.Error())
}
}
return nil

View File

@@ -5,27 +5,30 @@ import (
"encoding/json"
"fmt"
"io"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/model"
"one-api/relay"
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"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 {
common.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
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 {
common.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
logger.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
if len(taskIds) == 0 {
return nil
}
@@ -37,7 +40,7 @@ func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, cha
"progress": "100%",
})
if errUpdate != nil {
common.SysError(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
common.SysLog(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
}
return fmt.Errorf("CacheGetChannel failed: %w", err)
}
@@ -45,9 +48,14 @@ func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, cha
if adaptor == nil {
return fmt.Errorf("video adaptor not found")
}
info := &relaycommon.RelayInfo{}
info.ChannelMeta = &relaycommon.ChannelMeta{
ChannelBaseUrl: cacheGetChannel.GetBaseURL(),
}
adaptor.Init(info)
for _, taskId := range taskIds {
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
common.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
}
}
return nil
@@ -61,7 +69,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
task := taskM[taskId]
if task == nil {
common.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
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{
@@ -90,10 +98,11 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
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 = responseBody
task.Data = redactVideoResponseBody(responseBody)
}
now := time.Now().Unix()
@@ -116,7 +125,94 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
if task.FinishTime == 0 {
task.FinishTime = now
}
task.FailReason = taskResult.Url
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 {
// 获取用户和组的倍率信息
user, err := model.GetUserById(task.UserId, false)
if err == nil {
groupRatio := ratio_setting.GetGroupRatio(user.Group)
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(user.Group, user.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预扣费%stokens%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,分组倍率 %.2ftokens %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预扣费%stokens%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,分组倍率 %.2ftokens %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 预扣费准确(%stokens%d",
task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens))
}
}
}
}
}
}
case model.TaskStatusFailure:
task.Status = model.TaskStatusFailure
task.Progress = "100%"
@@ -124,13 +220,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
task.FinishTime = now
}
task.FailReason = taskResult.Reason
common.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
quota := task.Quota
if quota != 0 {
if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil {
common.LogError(ctx, "Failed to increase user quota: "+err.Error())
logger.LogError(ctx, "Failed to increase user quota: "+err.Error())
}
logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, common.LogQuota(quota))
logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, logger.LogQuota(quota))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
default:
@@ -140,8 +236,42 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
task.Progress = taskResult.Progress
}
if err := task.Update(); err != nil {
common.SysError("UpdateVideoTask task error: " + err.Error())
common.SysLog("UpdateVideoTask task error: " + err.Error())
}
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] + "..."
}

View File

@@ -6,10 +6,11 @@ import (
"encoding/hex"
"io"
"net/http"
"one-api/common"
"one-api/model"
"sort"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
@@ -65,7 +66,7 @@ func TelegramBind(c *gin.Context) {
return
}
c.Redirect(302, "/setting")
c.Redirect(302, "/console/personal")
}
func TelegramLogin(c *gin.Context) {

View File

@@ -2,9 +2,11 @@ package controller
import (
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
@@ -82,6 +84,57 @@ func GetTokenStatus(c *gin.Context) {
})
}
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)
@@ -102,7 +155,7 @@ func AddToken(c *gin.Context) {
"success": false,
"message": "生成令牌失败",
})
common.SysError("failed to generate token key: " + err.Error())
common.SysLog("failed to generate token key: " + err.Error())
return
}
cleanToken := model.Token{

View File

@@ -4,20 +4,62 @@ import (
"fmt"
"log"
"net/url"
"one-api/common"
"one-api/model"
"one-api/service"
"one-api/setting"
"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 != "",
"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"`
@@ -30,13 +72,13 @@ type AmountRequest struct {
}
func GetEpayClient() *epay.Client {
if setting.PayAddress == "" || setting.EpayId == "" || setting.EpayKey == "" {
if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" {
return nil
}
withUrl, err := epay.NewClient(&epay.Config{
PartnerID: setting.EpayId,
Key: setting.EpayKey,
}, setting.PayAddress)
PartnerID: operation_setting.EpayId,
Key: operation_setting.EpayKey,
}, operation_setting.PayAddress)
if err != nil {
return nil
}
@@ -45,8 +87,9 @@ func GetEpayClient() *epay.Client {
func getPayMoney(amount int64, group string) float64 {
dAmount := decimal.NewFromInt(amount)
if !common.DisplayInCurrencyEnabled {
// 充值金额以“展示类型”为准:
// - USD/CNY: 前端传 amount 为金额单位TOKENS: 前端传 tokens需要换成 USD 金额
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
dAmount = dAmount.Div(dQuotaPerUnit)
}
@@ -57,16 +100,24 @@ func getPayMoney(amount int64, group string) float64 {
}
dTopupGroupRatio := decimal.NewFromFloat(topupGroupRatio)
dPrice := decimal.NewFromFloat(setting.Price)
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)
payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio).Mul(dDiscount)
return payMoney.InexactFloat64()
}
func getMinTopup() int64 {
minTopup := setting.MinTopUp
if !common.DisplayInCurrencyEnabled {
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())
@@ -98,13 +149,13 @@ func RequestEpay(c *gin.Context) {
return
}
if !setting.ContainsPayMethod(req.PaymentMethod) {
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"})
return
}
callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log")
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)
@@ -127,18 +178,19 @@ func RequestEpay(c *gin.Context) {
return
}
amount := req.Amount
if !common.DisplayInCurrencyEnabled {
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,
CreateTime: time.Now().Unix(),
Status: "pending",
UserId: id,
Amount: amount,
Money: payMoney,
TradeNo: tradeNo,
PaymentMethod: req.PaymentMethod,
CreateTime: time.Now().Unix(),
Status: "pending",
}
err = topUp.Insert()
if err != nil {
@@ -186,8 +238,8 @@ func EpayNotify(c *gin.Context) {
_, err := c.Writer.Write([]byte("fail"))
if err != nil {
log.Println("易支付回调写入失败")
return
}
return
}
verifyInfo, err := client.Verify(params)
if err == nil && verifyInfo.VerifyStatus {
@@ -231,7 +283,7 @@ func EpayNotify(c *gin.Context) {
return
}
log.Printf("易支付回调更新用户成功 %v", topUp)
model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v支付金额%f", common.LogQuota(quotaToAdd), topUp.Money))
model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v支付金额%f", logger.LogQuota(quotaToAdd), topUp.Money))
}
} else {
log.Printf("易支付异常回调: %v", verifyInfo)
@@ -263,3 +315,76 @@ func RequestAmount(c *gin.Context) {
}
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)
}

View File

@@ -5,13 +5,16 @@ import (
"io"
"log"
"net/http"
"one-api/common"
"one-api/model"
"one-api/setting"
"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"
@@ -81,12 +84,13 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
}
topUp := &model.TopUp{
UserId: id,
Amount: req.Amount,
Money: chargedMoney,
TradeNo: referenceId,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
UserId: id,
Amount: req.Amount,
Money: chargedMoney,
TradeNo: referenceId,
PaymentMethod: PaymentMethodStripe,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
err = topUp.Insert()
if err != nil {
@@ -215,15 +219,16 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(referenceId),
SuccessURL: stripe.String(setting.ServerAddress + "/log"),
CancelURL: stripe.String(setting.ServerAddress + "/topup"),
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/log"),
CancelURL: stripe.String(system_setting.ServerAddress + "/topup"),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(setting.StripePriceId),
Quantity: stripe.Int64(amount),
},
},
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
AllowPromotionCodes: stripe.Bool(setting.StripePromotionCodesEnabled),
}
if "" == customerId {
@@ -254,7 +259,8 @@ func GetChargedAmount(count float64, user model.User) float64 {
}
func getStripePayMoney(amount float64, group string) float64 {
if !common.DisplayInCurrencyEnabled {
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
@@ -262,13 +268,20 @@ func getStripePayMoney(amount float64, group string) float64 {
if topupGroupRatio == 0 {
topupGroupRatio = 1
}
payMoney := amount * setting.StripeUnitPrice * topupGroupRatio
// 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 !common.DisplayInCurrencyEnabled {
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
minTopup = minTopup * int(common.QuotaPerUnit)
}
return int64(minTopup)

View File

@@ -4,10 +4,11 @@ import (
"errors"
"fmt"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
@@ -70,7 +71,7 @@ func Setup2FA(c *gin.Context) {
"success": false,
"message": "生成2FA密钥失败",
})
common.SysError("生成TOTP密钥失败: " + err.Error())
common.SysLog("生成TOTP密钥失败: " + err.Error())
return
}
@@ -81,7 +82,7 @@ func Setup2FA(c *gin.Context) {
"success": false,
"message": "生成备用码失败",
})
common.SysError("生成备用码失败: " + err.Error())
common.SysLog("生成备用码失败: " + err.Error())
return
}
@@ -115,7 +116,7 @@ func Setup2FA(c *gin.Context) {
"success": false,
"message": "保存备用码失败",
})
common.SysError("保存备用码失败: " + err.Error())
common.SysLog("保存备用码失败: " + err.Error())
return
}
@@ -294,7 +295,7 @@ func Get2FAStatus(c *gin.Context) {
// 获取剩余备用码数量
backupCount, err := model.GetUnusedBackupCodeCount(userId)
if err != nil {
common.SysError("获取备用码数量失败: " + err.Error())
common.SysLog("获取备用码数量失败: " + err.Error())
} else {
status["backup_codes_remaining"] = backupCount
}
@@ -368,7 +369,7 @@ func RegenerateBackupCodes(c *gin.Context) {
"success": false,
"message": "生成备用码失败",
})
common.SysError("生成备用码失败: " + err.Error())
common.SysLog("生成备用码失败: " + err.Error())
return
}
@@ -378,7 +379,7 @@ func RegenerateBackupCodes(c *gin.Context) {
"success": false,
"message": "保存备用码失败",
})
common.SysError("保存备用码失败: " + err.Error())
common.SysLog("保存备用码失败: " + err.Error())
return
}

View File

@@ -5,11 +5,12 @@ import (
"encoding/json"
"errors"
"net/http"
"one-api/setting/console_setting"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/gin-gonic/gin"
"golang.org/x/sync/errgroup"
)
@@ -31,7 +32,7 @@ type Monitor struct {
type UptimeGroupResult struct {
CategoryName string `json:"categoryName"`
Monitors []Monitor `json:"monitors"`
Monitors []Monitor `json:"monitors"`
}
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
@@ -57,29 +58,29 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
url, _ := groupConfig["url"].(string)
slug, _ := groupConfig["slug"].(string)
categoryName, _ := groupConfig["categoryName"].(string)
result := UptimeGroupResult{
CategoryName: categoryName,
Monitors: []Monitor{},
Monitors: []Monitor{},
}
if url == "" || slug == "" {
return result
}
baseURL := strings.TrimSuffix(url, "/")
var statusData struct {
PublicGroupList []struct {
ID int `json:"id"`
Name string `json:"name"`
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"`
@@ -88,11 +89,11 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
}
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+apiStatusPath+slug, &statusData)
})
g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
})
if g.Wait() != nil {
@@ -139,7 +140,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
client := &http.Client{Timeout: httpTimeout}
results := make([]UptimeGroupResult, len(groups))
g, gCtx := errgroup.WithContext(ctx)
for i, group := range groups {
i, group := i, group
@@ -148,7 +149,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
return nil
})
}
g.Wait()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results})
}
}

View File

@@ -2,10 +2,11 @@ package controller
import (
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)

View File

@@ -5,15 +5,17 @@ import (
"fmt"
"net/http"
"net/url"
"one-api/common"
"one-api/dto"
"one-api/model"
"one-api/setting"
"strconv"
"strings"
"sync"
"one-api/constant"
"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/setting"
"github.com/QuantumNous/new-api/constant"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
@@ -62,7 +64,7 @@ func Login(c *gin.Context) {
})
return
}
// 检查是否启用2FA
if model.IsTwoFAEnabled(user.Id) {
// 设置pending session等待2FA验证
@@ -77,7 +79,7 @@ func Login(c *gin.Context) {
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "请输入两步验证码",
"success": true,
@@ -87,7 +89,7 @@ func Login(c *gin.Context) {
})
return
}
setupLogin(&user, c)
}
@@ -192,7 +194,7 @@ func Register(c *gin.Context) {
"success": false,
"message": "数据库错误,请稍后重试",
})
common.SysError(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err))
common.SysLog(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err))
return
}
if exist {
@@ -209,6 +211,7 @@ func Register(c *gin.Context) {
Password: user.Password,
DisplayName: user.Username,
InviterId: inviterId,
Role: common.RoleCommonUser, // 明确设置角色为普通用户
}
if common.EmailVerificationEnabled {
cleanUser.Email = user.Email
@@ -235,7 +238,7 @@ func Register(c *gin.Context) {
"success": false,
"message": "生成默认令牌失败",
})
common.SysError("failed to generate token key: " + err.Error())
common.SysLog("failed to generate token key: " + err.Error())
return
}
// 生成默认令牌
@@ -342,7 +345,7 @@ func GenerateAccessToken(c *gin.Context) {
"success": false,
"message": "生成失败",
})
common.SysError("failed to generate key: " + err.Error())
common.SysLog("failed to generate key: " + err.Error())
return
}
user.SetAccessToken(key)
@@ -425,6 +428,7 @@ func GetAffCode(c *gin.Context) {
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)
@@ -433,14 +437,138 @@ func GetSelf(c *gin.Context) {
// 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,
"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": user,
"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 {
@@ -517,7 +645,7 @@ func UpdateUser(c *gin.Context) {
return
}
if originUser.Quota != updatedUser.Quota {
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", common.LogQuota(originUser.Quota), common.LogQuota(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,
@@ -527,8 +655,8 @@ func UpdateUser(c *gin.Context) {
}
func UpdateSelf(c *gin.Context) {
var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user)
var requestData map[string]interface{}
err := json.NewDecoder(c.Request.Body).Decode(&requestData)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -536,6 +664,60 @@ func UpdateSelf(c *gin.Context) {
})
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 :)
}
@@ -678,6 +860,7 @@ func CreateUser(c *gin.Context) {
Username: user.Username,
Password: user.Password,
DisplayName: user.DisplayName,
Role: user.Role, // 保持管理员设置的角色
}
if err := cleanUser.Insert(0); err != nil {
common.ApiError(c, err)
@@ -843,18 +1026,64 @@ type topUpRequest struct {
Key string `json:"key"`
}
var topUpLock = sync.Mutex{}
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) {
topUpLock.Lock()
defer topUpLock.Unlock()
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
}
id := c.GetInt("id")
quota, err := model.Redeem(req.Key, id)
if err != nil {
common.ApiError(c, err)
@@ -865,7 +1094,6 @@ func TopUp(c *gin.Context) {
"message": "",
"data": quota,
})
return
}
type UpdateUserSettingRequest struct {
@@ -874,6 +1102,10 @@ type UpdateUserSettingRequest struct {
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"`
}
@@ -889,7 +1121,7 @@ func UpdateUserSetting(c *gin.Context) {
}
// 验证预警类型
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook {
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": "无效的预警类型",
@@ -937,6 +1169,67 @@ func UpdateUserSetting(c *gin.Context) {
}
}
// 如果是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 {
@@ -965,6 +1258,23 @@ func UpdateUserSetting(c *gin.Context) {
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 {

View File

@@ -1,124 +1,124 @@
package controller
import (
"strconv"
"strconv"
"one-api/common"
"one-api/model"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"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)
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)
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)
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
}
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)
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
}
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)
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)
}
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)
}

130
controller/video_proxy.go Normal file
View File

@@ -0,0 +1,130 @@
package controller
import (
"fmt"
"io"
"net/http"
"time"
"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: %s", taskID, err.Error()))
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 channel %d: %s", task.ChannelId, err.Error()))
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"
}
videoURL := fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID)
client := &http.Client{
Timeout: 60 * time.Second,
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, videoURL, nil)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create request for %s: %s", videoURL, err.Error()))
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "Failed to create proxy request",
"type": "server_error",
},
})
return
}
req.Header.Set("Authorization", "Bearer "+channel.Key)
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()))
}
}

View File

@@ -5,11 +5,12 @@ import (
"errors"
"fmt"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)

View File

@@ -0,0 +1,66 @@
package interfaces
import (
"io"
"net/http"
"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"
)
// ChannelPlugin 定义Channel插件接口
// 继承原有的Adaptor接口增加插件元数据
type ChannelPlugin interface {
// 插件元数据
Name() string
Version() string
Priority() int
// 原有Adaptor接口方法
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)
}
// TaskChannelPlugin 定义Task类型的Channel插件接口
type TaskChannelPlugin interface {
// 插件元数据
Name() string
Version() string
Priority() int
// 原有TaskAdaptor接口方法
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(baseUrl, key string, body map[string]any) (*http.Response, error)
ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error)
}
// ChannelConfig 插件配置
type ChannelConfig struct {
Enabled bool `yaml:"enabled"`
Priority int `yaml:"priority"`
Config map[string]interface{} `yaml:"config"`
}

93
core/interfaces/hook.go Normal file
View File

@@ -0,0 +1,93 @@
package interfaces
import (
"io"
"net/http"
"github.com/gin-gonic/gin"
)
// HookContext Relay Hook执行上下文
type HookContext struct {
// Gin Context
GinContext *gin.Context
// Request相关
Request *http.Request
RequestBody []byte
// Response相关
Response *http.Response
ResponseBody []byte
// Channel信息
ChannelID int
ChannelType int
ChannelName string
// Model信息
Model string
OriginalModel string
// User信息
UserID int
TokenID int
Group string
// 扩展数据(插件间共享)
Data map[string]interface{}
// 错误信息
Error error
// 是否跳过后续处理
ShouldSkip bool
}
// RelayHook Relay Hook接口
type RelayHook interface {
// 插件元数据
Name() string
Priority() int
Enabled() bool
// 生命周期钩子
// OnBeforeRequest 在请求发送到上游之前执行
OnBeforeRequest(ctx *HookContext) error
// OnAfterResponse 在收到上游响应之后执行
OnAfterResponse(ctx *HookContext) error
// OnError 在发生错误时执行
OnError(ctx *HookContext, err error) error
}
// RequestModifier 请求修改器接口
// 实现此接口的Hook可以修改请求内容
type RequestModifier interface {
RelayHook
ModifyRequest(ctx *HookContext, body io.Reader) (io.Reader, error)
}
// ResponseProcessor 响应处理器接口
// 实现此接口的Hook可以处理响应内容
type ResponseProcessor interface {
RelayHook
ProcessResponse(ctx *HookContext, body []byte) ([]byte, error)
}
// StreamProcessor 流式响应处理器接口
// 实现此接口的Hook可以处理流式响应
type StreamProcessor interface {
RelayHook
ProcessStreamChunk(ctx *HookContext, chunk []byte) ([]byte, error)
}
// HookConfig Hook配置
type HookConfig struct {
Name string `yaml:"name"`
Enabled bool `yaml:"enabled"`
Priority int `yaml:"priority"`
Config map[string]interface{} `yaml:"config"`
}

View File

@@ -0,0 +1,31 @@
package interfaces
import (
"github.com/gin-gonic/gin"
)
// MiddlewarePlugin 中间件插件接口
type MiddlewarePlugin interface {
// 插件元数据
Name() string
Priority() int
Enabled() bool
// 返回Gin中间件处理函数
Handler() gin.HandlerFunc
// 初始化(可选)
Initialize(config MiddlewareConfig) error
}
// MiddlewareConfig 中间件配置
type MiddlewareConfig struct {
Name string `yaml:"name"`
Enabled bool `yaml:"enabled"`
Priority int `yaml:"priority"`
Config map[string]interface{} `yaml:"config"`
}
// MiddlewareFactory 中间件工厂函数类型
type MiddlewareFactory func(config MiddlewareConfig) (MiddlewarePlugin, error)

View File

@@ -0,0 +1,171 @@
package registry
import (
"fmt"
"sync"
"github.com/QuantumNous/new-api/core/interfaces"
)
var (
// 全局Channel注册表
channelRegistry = &ChannelRegistry{plugins: make(map[int]interfaces.ChannelPlugin)}
channelRegistryLock sync.RWMutex
// 全局TaskChannel注册表
taskChannelRegistry = &TaskChannelRegistry{plugins: make(map[string]interfaces.TaskChannelPlugin)}
taskChannelRegistryLock sync.RWMutex
)
// ChannelRegistry Channel插件注册中心
type ChannelRegistry struct {
plugins map[int]interfaces.ChannelPlugin // channelType -> plugin
mu sync.RWMutex
}
// Register 注册Channel插件
func (r *ChannelRegistry) Register(channelType int, plugin interfaces.ChannelPlugin) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.plugins[channelType]; exists {
return fmt.Errorf("channel plugin for type %d already registered", channelType)
}
r.plugins[channelType] = plugin
return nil
}
// Get 获取Channel插件
func (r *ChannelRegistry) Get(channelType int) (interfaces.ChannelPlugin, error) {
r.mu.RLock()
defer r.mu.RUnlock()
plugin, exists := r.plugins[channelType]
if !exists {
return nil, fmt.Errorf("channel plugin for type %d not found", channelType)
}
return plugin, nil
}
// List 列出所有已注册的Channel插件
func (r *ChannelRegistry) List() []interfaces.ChannelPlugin {
r.mu.RLock()
defer r.mu.RUnlock()
plugins := make([]interfaces.ChannelPlugin, 0, len(r.plugins))
for _, plugin := range r.plugins {
plugins = append(plugins, plugin)
}
return plugins
}
// Has 检查是否存在指定的Channel插件
func (r *ChannelRegistry) Has(channelType int) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, exists := r.plugins[channelType]
return exists
}
// TaskChannelRegistry TaskChannel插件注册中心
type TaskChannelRegistry struct {
plugins map[string]interfaces.TaskChannelPlugin // platform -> plugin
mu sync.RWMutex
}
// Register 注册TaskChannel插件
func (r *TaskChannelRegistry) Register(platform string, plugin interfaces.TaskChannelPlugin) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.plugins[platform]; exists {
return fmt.Errorf("task channel plugin for platform %s already registered", platform)
}
r.plugins[platform] = plugin
return nil
}
// Get 获取TaskChannel插件
func (r *TaskChannelRegistry) Get(platform string) (interfaces.TaskChannelPlugin, error) {
r.mu.RLock()
defer r.mu.RUnlock()
plugin, exists := r.plugins[platform]
if !exists {
return nil, fmt.Errorf("task channel plugin for platform %s not found", platform)
}
return plugin, nil
}
// List 列出所有已注册的TaskChannel插件
func (r *TaskChannelRegistry) List() []interfaces.TaskChannelPlugin {
r.mu.RLock()
defer r.mu.RUnlock()
plugins := make([]interfaces.TaskChannelPlugin, 0, len(r.plugins))
for _, plugin := range r.plugins {
plugins = append(plugins, plugin)
}
return plugins
}
// 全局函数 - Channel Registry
// RegisterChannel 注册Channel插件
func RegisterChannel(channelType int, plugin interfaces.ChannelPlugin) error {
channelRegistryLock.Lock()
defer channelRegistryLock.Unlock()
return channelRegistry.Register(channelType, plugin)
}
// GetChannel 获取Channel插件
func GetChannel(channelType int) (interfaces.ChannelPlugin, error) {
channelRegistryLock.RLock()
defer channelRegistryLock.RUnlock()
return channelRegistry.Get(channelType)
}
// ListChannels 列出所有Channel插件
func ListChannels() []interfaces.ChannelPlugin {
channelRegistryLock.RLock()
defer channelRegistryLock.RUnlock()
return channelRegistry.List()
}
// HasChannel 检查是否存在指定的Channel插件
func HasChannel(channelType int) bool {
channelRegistryLock.RLock()
defer channelRegistryLock.RUnlock()
return channelRegistry.Has(channelType)
}
// 全局函数 - TaskChannel Registry
// RegisterTaskChannel 注册TaskChannel插件
func RegisterTaskChannel(platform string, plugin interfaces.TaskChannelPlugin) error {
taskChannelRegistryLock.Lock()
defer taskChannelRegistryLock.Unlock()
return taskChannelRegistry.Register(platform, plugin)
}
// GetTaskChannel 获取TaskChannel插件
func GetTaskChannel(platform string) (interfaces.TaskChannelPlugin, error) {
taskChannelRegistryLock.RLock()
defer taskChannelRegistryLock.RUnlock()
return taskChannelRegistry.Get(platform)
}
// ListTaskChannels 列出所有TaskChannel插件
func ListTaskChannels() []interfaces.TaskChannelPlugin {
taskChannelRegistryLock.RLock()
defer taskChannelRegistryLock.RUnlock()
return taskChannelRegistry.List()
}

View File

@@ -0,0 +1,183 @@
package registry
import (
"fmt"
"sort"
"sync"
"github.com/QuantumNous/new-api/core/interfaces"
)
var (
// 全局Hook注册表
hookRegistry = &HookRegistry{hooks: make([]interfaces.RelayHook, 0)}
hookRegistryLock sync.RWMutex
)
// HookRegistry Hook插件注册中心
type HookRegistry struct {
hooks []interfaces.RelayHook
sorted bool
mu sync.RWMutex
}
// Register 注册Hook插件
func (r *HookRegistry) Register(hook interfaces.RelayHook) error {
r.mu.Lock()
defer r.mu.Unlock()
// 检查是否已存在同名Hook
for _, h := range r.hooks {
if h.Name() == hook.Name() {
return fmt.Errorf("hook %s already registered", hook.Name())
}
}
r.hooks = append(r.hooks, hook)
r.sorted = false // 标记需要重新排序
return nil
}
// Get 获取指定名称的Hook插件
func (r *HookRegistry) Get(name string) (interfaces.RelayHook, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, hook := range r.hooks {
if hook.Name() == name {
return hook, nil
}
}
return nil, fmt.Errorf("hook %s not found", name)
}
// List 列出所有已注册且启用的Hook插件按优先级排序
func (r *HookRegistry) List() []interfaces.RelayHook {
r.mu.Lock()
defer r.mu.Unlock()
// 如果未排序,先排序
if !r.sorted {
r.sortHooks()
}
// 只返回启用的hooks
enabledHooks := make([]interfaces.RelayHook, 0)
for _, hook := range r.hooks {
if hook.Enabled() {
enabledHooks = append(enabledHooks, hook)
}
}
return enabledHooks
}
// ListAll 列出所有已注册的Hook插件包括未启用的
func (r *HookRegistry) ListAll() []interfaces.RelayHook {
r.mu.RLock()
defer r.mu.RUnlock()
hooks := make([]interfaces.RelayHook, len(r.hooks))
copy(hooks, r.hooks)
return hooks
}
// sortHooks 按优先级排序hooks优先级数字越大越先执行
func (r *HookRegistry) sortHooks() {
sort.SliceStable(r.hooks, func(i, j int) bool {
return r.hooks[i].Priority() > r.hooks[j].Priority()
})
r.sorted = true
}
// Has 检查是否存在指定的Hook插件
func (r *HookRegistry) Has(name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
for _, hook := range r.hooks {
if hook.Name() == name {
return true
}
}
return false
}
// Count 返回已注册的Hook数量
func (r *HookRegistry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.hooks)
}
// EnabledCount 返回已启用的Hook数量
func (r *HookRegistry) EnabledCount() int {
r.mu.RLock()
defer r.mu.RUnlock()
count := 0
for _, hook := range r.hooks {
if hook.Enabled() {
count++
}
}
return count
}
// 全局函数
// RegisterHook 注册Hook插件
func RegisterHook(hook interfaces.RelayHook) error {
hookRegistryLock.Lock()
defer hookRegistryLock.Unlock()
return hookRegistry.Register(hook)
}
// GetHook 获取Hook插件
func GetHook(name string) (interfaces.RelayHook, error) {
hookRegistryLock.RLock()
defer hookRegistryLock.RUnlock()
return hookRegistry.Get(name)
}
// ListHooks 列出所有已启用的Hook插件
func ListHooks() []interfaces.RelayHook {
hookRegistryLock.RLock()
defer hookRegistryLock.RUnlock()
return hookRegistry.List()
}
// ListAllHooks 列出所有Hook插件
func ListAllHooks() []interfaces.RelayHook {
hookRegistryLock.RLock()
defer hookRegistryLock.RUnlock()
return hookRegistry.ListAll()
}
// HasHook 检查是否存在指定的Hook插件
func HasHook(name string) bool {
hookRegistryLock.RLock()
defer hookRegistryLock.RUnlock()
return hookRegistry.Has(name)
}
// HookCount 返回已注册的Hook数量
func HookCount() int {
hookRegistryLock.RLock()
defer hookRegistryLock.RUnlock()
return hookRegistry.Count()
}
// EnabledHookCount 返回已启用的Hook数量
func EnabledHookCount() int {
hookRegistryLock.RLock()
defer hookRegistryLock.RUnlock()
return hookRegistry.EnabledCount()
}

View File

@@ -0,0 +1,133 @@
package registry
import (
"fmt"
"sort"
"sync"
"github.com/QuantumNous/new-api/core/interfaces"
)
var (
// 全局Middleware注册表
middlewareRegistry = &MiddlewareRegistry{plugins: make(map[string]interfaces.MiddlewarePlugin)}
middlewareRegistryLock sync.RWMutex
)
// MiddlewareRegistry 中间件插件注册中心
type MiddlewareRegistry struct {
plugins map[string]interfaces.MiddlewarePlugin
mu sync.RWMutex
}
// Register 注册Middleware插件
func (r *MiddlewareRegistry) Register(plugin interfaces.MiddlewarePlugin) error {
r.mu.Lock()
defer r.mu.Unlock()
name := plugin.Name()
if _, exists := r.plugins[name]; exists {
return fmt.Errorf("middleware plugin %s already registered", name)
}
r.plugins[name] = plugin
return nil
}
// Get 获取Middleware插件
func (r *MiddlewareRegistry) Get(name string) (interfaces.MiddlewarePlugin, error) {
r.mu.RLock()
defer r.mu.RUnlock()
plugin, exists := r.plugins[name]
if !exists {
return nil, fmt.Errorf("middleware plugin %s not found", name)
}
return plugin, nil
}
// List 列出所有已注册的Middleware插件按优先级排序
func (r *MiddlewareRegistry) List() []interfaces.MiddlewarePlugin {
r.mu.RLock()
defer r.mu.RUnlock()
plugins := make([]interfaces.MiddlewarePlugin, 0, len(r.plugins))
for _, plugin := range r.plugins {
plugins = append(plugins, plugin)
}
// 按优先级排序(优先级数字越大越先执行)
sort.SliceStable(plugins, func(i, j int) bool {
return plugins[i].Priority() > plugins[j].Priority()
})
return plugins
}
// ListEnabled 列出所有已启用的Middleware插件按优先级排序
func (r *MiddlewareRegistry) ListEnabled() []interfaces.MiddlewarePlugin {
r.mu.RLock()
defer r.mu.RUnlock()
plugins := make([]interfaces.MiddlewarePlugin, 0, len(r.plugins))
for _, plugin := range r.plugins {
if plugin.Enabled() {
plugins = append(plugins, plugin)
}
}
// 按优先级排序
sort.SliceStable(plugins, func(i, j int) bool {
return plugins[i].Priority() > plugins[j].Priority()
})
return plugins
}
// Has 检查是否存在指定的Middleware插件
func (r *MiddlewareRegistry) Has(name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, exists := r.plugins[name]
return exists
}
// 全局函数
// RegisterMiddleware 注册Middleware插件
func RegisterMiddleware(plugin interfaces.MiddlewarePlugin) error {
middlewareRegistryLock.Lock()
defer middlewareRegistryLock.Unlock()
return middlewareRegistry.Register(plugin)
}
// GetMiddleware 获取Middleware插件
func GetMiddleware(name string) (interfaces.MiddlewarePlugin, error) {
middlewareRegistryLock.RLock()
defer middlewareRegistryLock.RUnlock()
return middlewareRegistry.Get(name)
}
// ListMiddlewares 列出所有Middleware插件
func ListMiddlewares() []interfaces.MiddlewarePlugin {
middlewareRegistryLock.RLock()
defer middlewareRegistryLock.RUnlock()
return middlewareRegistry.List()
}
// ListEnabledMiddlewares 列出所有已启用的Middleware插件
func ListEnabledMiddlewares() []interfaces.MiddlewarePlugin {
middlewareRegistryLock.RLock()
defer middlewareRegistryLock.RUnlock()
return middlewareRegistry.ListEnabled()
}
// HasMiddleware 检查是否存在指定的Middleware插件
func HasMiddleware(name string) bool {
middlewareRegistryLock.RLock()
defer middlewareRegistryLock.RUnlock()
return middlewareRegistry.Has(name)
}

View File

@@ -0,0 +1,116 @@
package registry
import (
"testing"
"github.com/QuantumNous/new-api/core/interfaces"
)
// Mock Hook实现
type mockHook struct {
name string
priority int
enabled bool
}
func (m *mockHook) Name() string { return m.name }
func (m *mockHook) Priority() int { return m.priority }
func (m *mockHook) Enabled() bool { return m.enabled }
func (m *mockHook) OnBeforeRequest(ctx *interfaces.HookContext) error { return nil }
func (m *mockHook) OnAfterResponse(ctx *interfaces.HookContext) error { return nil }
func (m *mockHook) OnError(ctx *interfaces.HookContext, err error) error { return nil }
func TestHookRegistry(t *testing.T) {
// 创建新的注册表(用于测试)
registry := &HookRegistry{hooks: make([]interfaces.RelayHook, 0)}
// 测试注册Hook
hook1 := &mockHook{name: "test_hook_1", priority: 100, enabled: true}
hook2 := &mockHook{name: "test_hook_2", priority: 50, enabled: true}
hook3 := &mockHook{name: "test_hook_3", priority: 75, enabled: false}
if err := registry.Register(hook1); err != nil {
t.Errorf("Failed to register hook1: %v", err)
}
if err := registry.Register(hook2); err != nil {
t.Errorf("Failed to register hook2: %v", err)
}
if err := registry.Register(hook3); err != nil {
t.Errorf("Failed to register hook3: %v", err)
}
// 测试重复注册
if err := registry.Register(hook1); err == nil {
t.Error("Expected error when registering duplicate hook")
}
// 测试获取Hook
if hook, err := registry.Get("test_hook_1"); err != nil {
t.Errorf("Failed to get hook: %v", err)
} else if hook.Name() != "test_hook_1" {
t.Errorf("Got wrong hook: %s", hook.Name())
}
// 测试不存在的Hook
if _, err := registry.Get("nonexistent"); err == nil {
t.Error("Expected error when getting nonexistent hook")
}
// 测试List应该只返回enabled的hooks
hooks := registry.List()
if len(hooks) != 2 {
t.Errorf("Expected 2 enabled hooks, got %d", len(hooks))
}
// 测试优先级排序100应该在50之前
if hooks[0].Priority() != 100 {
t.Errorf("Expected first hook to have priority 100, got %d", hooks[0].Priority())
}
// 测试Count
if count := registry.Count(); count != 3 {
t.Errorf("Expected count 3, got %d", count)
}
// 测试EnabledCount
if count := registry.EnabledCount(); count != 2 {
t.Errorf("Expected enabled count 2, got %d", count)
}
// 测试Has
if !registry.Has("test_hook_1") {
t.Error("Expected to find test_hook_1")
}
if registry.Has("nonexistent") {
t.Error("Should not find nonexistent hook")
}
}
func TestChannelRegistry(t *testing.T) {
// 这里可以添加Channel Registry的测试
// 但需要mock ChannelPlugin接口比较复杂
// 作为示例,我们只测试基本功能
registry := &ChannelRegistry{plugins: make(map[int]interfaces.ChannelPlugin)}
// 测试Has方法
if registry.Has(1) {
t.Error("Should not find channel type 1")
}
}
func TestMiddlewareRegistry(t *testing.T) {
// Middleware Registry测试
// 需要mock MiddlewarePlugin接口
registry := &MiddlewareRegistry{plugins: make(map[string]interfaces.MiddlewarePlugin)}
// 测试Has方法
if registry.Has("test_middleware") {
t.Error("Should not find test_middleware")
}
}

View File

@@ -1,4 +1,18 @@
version: '3.4'
# 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:
@@ -12,21 +26,22 @@ services:
- ./data:/data
- ./logs:/app/logs
environment:
- SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service
- 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 # 是否启用错误日志记录
# - STREAMING_TIMEOUT=120 # 流模式无响应超时时间单位秒默认120秒如果出现空补全可以尝试改为更大值
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!!!!!!!
# - NODE_TYPE=slave # Uncomment for slave node in multi-node deployment
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
# - FRONTEND_BASE_URL=https://openai.justsong.cn # Uncomment for multi-node deployment with front-end URL
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 batch update enabled
# - 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
depends_on:
- redis
- mysql
- postgres
# - mysql # Uncomment if using MySQL
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk -F: '{print $$2}'"]
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
interval: 30s
timeout: 10s
retries: 3
@@ -36,17 +51,31 @@ services:
container_name: redis
restart: always
mysql:
image: mysql:8.2
container_name: mysql
postgres:
image: postgres:15
container_name: postgres
restart: always
environment:
MYSQL_ROOT_PASSWORD: 123456 # Ensure this matches the password in SQL_DSN
MYSQL_DATABASE: new-api
POSTGRES_USER: root
POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
POSTGRES_DB: new-api
volumes:
- mysql_data:/var/lib/mysql
# ports:
# - "3306:3306" # If you want to access MySQL from outside Docker, uncomment
- 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:
mysql_data:
pg_data:
# mysql_data:

View File

@@ -1,6 +1,6 @@
# One API Web 界面后端接口文档
# New API Web 界面后端接口文档
> 本文档汇总了 **One API** 后端提供给前端 Web 界面的全部 REST 接口(不含 *Relay* 相关接口)。
> 本文档汇总了 **New API** 后端提供给前端 Web 界面的全部 REST 接口(不含 *Relay* 相关接口)。
>
> 接口前缀统一为 `https://<your-domain>`,以下仅列出 **路径**、**HTTP 方法**、**鉴权要求** 与 **功能简介**。
>
@@ -62,6 +62,8 @@
| GET | /api/user/groups | 公开 | 列出所有分组(无鉴权版) |
### 5.2 用户自身操作 (需登录)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/user/self/groups | 用户 | 获取自己所在分组 |
| GET | /api/user/self | 用户 | 获取个人资料 |
| GET | /api/user/models | 用户 | 获取模型可见性 |
@@ -192,4 +194,4 @@
---
> **更新日期**2025.07.17
> **更新日期**2025.07.17

View File

@@ -0,0 +1,359 @@
# New-API 插件化架构说明
## 完整目录结构
```
new-api-2/
├── core/ # 核心层(高性能,不可插件化)
│ ├── interfaces/ # 插件接口定义
│ │ ├── channel.go # Channel插件接口
│ │ ├── hook.go # Hook插件接口
│ │ └── middleware.go # Middleware插件接口
│ └── registry/ # 插件注册中心
│ ├── channel_registry.go # Channel注册器线程安全
│ ├── hook_registry.go # Hook注册器优先级排序
│ └── middleware_registry.go # Middleware注册器
├── plugins/ # 🔵 Tier 1: 编译时插件(已实施)
│ ├── channels/ # Channel插件
│ │ ├── base_plugin.go # 基础插件包装器
│ │ └── registry.go # 自动注册31个AI Provider
│ └── hooks/ # Hook插件
│ ├── web_search/ # 联网搜索Hook
│ │ ├── web_search_hook.go
│ │ └── init.go
│ └── content_filter/ # 内容过滤Hook
│ ├── content_filter_hook.go
│ └── init.go
├── marketplace/ # 🟣 Tier 2: 运行时插件待实施Phase 2
│ ├── loader/ # go-plugin加载器
│ │ ├── plugin_client.go # 插件客户端
│ │ ├── plugin_server.go # 插件服务器
│ │ └── lifecycle.go # 生命周期管理
│ ├── manager/ # 插件管理器
│ │ ├── installer.go # 安装/卸载
│ │ ├── updater.go # 版本更新
│ │ └── registry.go # 插件注册表
│ ├── security/ # 安全模块
│ │ ├── signature.go # Ed25519签名验证
│ │ ├── checksum.go # SHA256校验
│ │ └── sandbox.go # 沙箱配置
│ ├── store/ # 插件商店客户端
│ │ ├── client.go # 商店API客户端
│ │ ├── search.go # 搜索功能
│ │ └── download.go # 下载管理
│ └── proto/ # gRPC协议定义
│ ├── hook.proto # Hook插件协议
│ ├── channel.proto # Channel插件协议
│ └── common.proto # 通用消息
├── plugins_external/ # 第三方插件安装目录
│ ├── installed/ # 已安装插件
│ │ ├── awesome-hook-v1.0.0/
│ │ ├── custom-llm-v2.1.0/
│ │ └── slack-notify-v1.5.0/
│ ├── cache/ # 下载缓存
│ └── temp/ # 临时文件
├── relay/ # Relay层
│ ├── hooks/ # Hook执行链
│ │ ├── chain.go # Hook链管理器
│ │ ├── context.go # Hook上下文
│ │ └── context_builder.go # 上下文构建器
│ └── relay_adaptor.go # Channel适配器优先从Registry获取
├── config/ # 配置系统
│ ├── plugins.yaml # 插件配置Tier 1 + Tier 2
│ └── plugin_config.go # 配置加载器(支持环境变量)
└── (其他现有目录保持不变)
```
---
## 完整架构图
### 系统架构总览
```mermaid
graph TB
subgraph "🌐 API层"
Client[客户端请求]
end
subgraph "🔐 中间件层"
Auth[认证中间件]
RateLimit[限流中间件]
Cache[缓存中间件]
end
subgraph "🎯 核心层 Core"
Registry[插件注册中心]
ChannelReg[Channel Registry]
HookReg[Hook Registry]
MidReg[Middleware Registry]
Registry --> ChannelReg
Registry --> HookReg
Registry --> MidReg
end
subgraph "🔵 Tier 1: 编译时插件(已实施)"
direction TB
Channels[31个 Channel Plugins]
OpenAI[OpenAI]
Claude[Claude]
Gemini[Gemini]
Others[其他28个...]
Channels --> OpenAI
Channels --> Claude
Channels --> Gemini
Channels --> Others
Hooks[Hook Plugins]
WebSearch[Web Search Hook]
ContentFilter[Content Filter Hook]
Hooks --> WebSearch
Hooks --> ContentFilter
end
subgraph "🟣 Tier 2: 运行时插件(待实施)"
direction TB
Marketplace[🏪 Plugin Marketplace]
ExtHook[External Hooks<br/>Python/Go/Node.js]
ExtChannel[External Channels<br/>小众AI提供商]
ExtMid[External Middleware<br/>企业集成]
ExtUI[UI Extensions<br/>自定义仪表板]
Marketplace --> ExtHook
Marketplace --> ExtChannel
Marketplace --> ExtMid
Marketplace --> ExtUI
end
subgraph "⚡ Relay执行流程"
direction LR
HookChain[Hook Chain]
BeforeHook[OnBeforeRequest]
ChannelAdaptor[Channel Adaptor]
AfterHook[OnAfterResponse]
HookChain --> BeforeHook
BeforeHook --> ChannelAdaptor
ChannelAdaptor --> AfterHook
end
subgraph "🌍 上游服务"
Upstream[AI Provider APIs]
end
Client --> Auth
Auth --> RateLimit
RateLimit --> Cache
Cache --> Registry
Channels --> ChannelReg
Hooks --> HookReg
Registry --> HookChain
HookChain --> Upstream
Upstream --> HookChain
Registry -.gRPC/RPC.-> ExtHook
Registry -.gRPC/RPC.-> ExtChannel
Registry -.gRPC/RPC.-> ExtMid
style Marketplace fill:#f9f,stroke:#333,stroke-width:4px
style Registry fill:#bbf,stroke:#333,stroke-width:4px
style Channels fill:#bfb,stroke:#333,stroke-width:2px
style Hooks fill:#bfb,stroke:#333,stroke-width:2px
```
### 双层插件系统架构
```mermaid
graph LR
subgraph "🔵 Tier 1: 编译时插件"
T1[性能: 100%<br/>语言: Go only<br/>部署: 编译到二进制]
T1Chan[31 Channels]
T1Hook[2 Hooks]
T1 --> T1Chan
T1 --> T1Hook
end
subgraph "🟣 Tier 2: 运行时插件"
T2[性能: 90-95%<br/>语言: Go/Python/Node.js<br/>部署: 独立进程]
T2Hook[External Hooks]
T2Chan[External Channels]
T2Mid[External Middleware]
T2UI[UI Extensions]
T2 --> T2Hook
T2 --> T2Chan
T2 --> T2Mid
T2 --> T2UI
end
T1 -.进程内调用.-> Core[Core System]
T2 -.gRPC/RPC.-> Core
style T1 fill:#bfb,stroke:#333,stroke-width:3px
style T2 fill:#f9f,stroke:#333,stroke-width:3px
style Core fill:#bbf,stroke:#333,stroke-width:3px
```
---
## 核心要点说明
### 1. 双层插件架构
| 层级 | 技术方案 | 性能 | 适用场景 | 开发语言 |
|------|---------|------|---------|---------|
| **Tier 1<br/>编译时插件** | 编译时链接 | 100%<br/>零损失 | • 核心ChannelOpenAI等<br/>• 内置Hook<br/>• 高频调用路径 | Go only |
| **Tier 2<br/>运行时插件** | go-plugin<br/>gRPC | 90-95%<br/>5-10%开销 | • 第三方扩展<br/>• 企业定制<br/>• 多语言集成 | Go/Python/<br/>Node.js/Rust |
### 2. 核心组件
#### Core层核心引擎
- **interfaces/**: 定义ChannelPlugin、RelayHook、MiddlewarePlugin接口
- **registry/**: 线程安全的插件注册中心支持O(1)查找、优先级排序
#### Relay Hook链
- **执行流程**: OnBeforeRequest → Channel.DoRequest → OnAfterResponse
- **特性**: 优先级排序、短路机制、数据共享HookContext.Data
- **应用场景**: 联网搜索、内容过滤、日志增强、缓存策略
### 3. Tier 1: 编译时插件(已实施 ✅)
**特点**:
- 零性能损失,编译后与硬编码无差异
- init()函数自动注册到Registry
- YAML配置启用/禁用
**已实现**:
- ✅ 31个Channel插件OpenAI、Claude、Gemini等
- ✅ 2个Hook插件web_search、content_filter
- ✅ Hook执行链
- ✅ 配置系统(支持环境变量展开)
### 4. Tier 2: 运行时插件(待实施 🚧)
**基于**: [hashicorp/go-plugin](https://github.com/hashicorp/go-plugin)Vault/Terraform使用
**优势**:
- ✅ 进程隔离(第三方代码崩溃不影响主程序)
- ✅ 多语言支持gRPC协议
- ✅ 热插拔(无需重启)
- ✅ 安全验证Ed25519签名 + SHA256校验 + TLS加密
- ✅ 独立分发(插件商店)
**适用场景**:
- 第三方开发者扩展
- 企业定制业务逻辑
- Python ML模型集成
- 第三方服务集成Slack/钉钉/企业微信)
- UI扩展
### 5. 安全机制
**Tier 1编译时**:
- 内部代码审查
- 编译期类型安全
**Tier 2运行时**:
- Ed25519签名验证
- SHA256校验和
- gRPC TLS加密
- 进程资源限制(内存/CPU
- 插件商店审核机制
- 可信发布者白名单
### 6. 配置系统
**单一配置文件**: `config/plugins.yaml`
```yaml
# Tier 1: 编译时插件
plugins:
hooks:
- name: web_search
enabled: false
priority: 50
config:
api_key: ${WEB_SEARCH_API_KEY}
# Tier 2: 运行时插件(待实施)
external_plugins:
enabled: true
hooks:
- name: awesome_hook
binary: awesome-hook-v1.0.0/awesome-hook
checksum: sha256:abc123...
# 插件商店
marketplace:
enabled: true
api_url: https://plugins.new-api.com
```
### 7. 性能对比
| 场景 | Tier 1 | Tier 2 | RPC开销 |
|------|--------|--------|--------|
| 核心Channel | 100% | N/A | 0% |
| 内置Hook | 100% | N/A | 0% |
| 第三方Hook | N/A | 92-95% | 5-8% |
| Python插件 | N/A | 88-92% | 8-12% |
### 8. 实施路线图
#### Phase 1: 编译时插件系统 ✅ 已完成
- Core Registry + Hook Chain
- 31个Channel插件 + 2个Hook示例
- YAML配置系统
#### Phase 2: go-plugin基础
- protobuf协议定义
- PluginLoader实现
- 签名验证系统
- Python/Go SDK
#### Phase 3: 插件商店
- 商店后端API
- Web UI搜索、安装、管理
- CLI工具
- 多语言SDK
### 9. 扩展示例
**新增Tier 1插件编译时**:
```go
// 1. 实现接口
type MyHook struct{}
func (h *MyHook) OnBeforeRequest(ctx *HookContext) error { /*...*/ }
// 2. 注册
func init() { registry.RegisterHook(&MyHook{}) }
// 3. 导入到main.go
import _ "github.com/xxx/plugins/hooks/my_hook"
```
**新增Tier 2插件运行时**:
```python
# external-plugin/my_hook.py
from new_api_plugin_sdk import HookPlugin, serve
class MyHook(HookPlugin):
def on_before_request(self, ctx):
return {"modified_body": ctx.request_body}
serve(MyHook())
```

BIN
docs/images/aliyun.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Some files were not shown because too many files have changed in this diff Show More