Compare commits

...

94 Commits

Author SHA1 Message Date
CaIon 59c582d13c fix: harden token auth error handling to prevent info leakage
- Create model/errors.go to centralize all sentinel errors
- ValidateAccessToken now returns error to distinguish DB failures
- ValidateUserToken uses unified ErrTokenInvalid for all auth failures
  (expired/exhausted/disabled/not-found) to prevent token enumeration
- authHelper and TokenAuthReadOnly use i18n messages instead of
  hardcoded Chinese strings
- All err.Error() removed from user-facing responses; DB errors logged
  server-side and return generic "contact admin" message (HTTP 500)
- Migrate ErrRedeemFailed, ErrTwoFANotEnabled to model/errors.go
2026-04-12 17:39:00 +08:00
CaIon 2819e3a1d1 fix: improve login error handling to distinguish database errors from auth failures
ValidateAndFill now checks the DB query result and returns sentinel errors
(ErrDatabase, ErrInvalidCredentials, ErrUserEmptyCredentials) instead of
hardcoded Chinese strings. The controller maps each sentinel to the
appropriate i18n message, so users see "please contact admin" on DB errors
instead of a misleading "wrong password" message. Non-DB errors still
return a unified vague response to avoid leaking user existence.
2026-04-12 17:11:20 +08:00
CaIon ed7f839911 feat: improve model price error UX with role-aware messages and cleaner UI
- Backend: differentiate error messages for admin vs regular users in price.go
- Backend: include error_code in channel test response for structured error handling
- Frontend: render model_price_error as a styled card in Playground with admin nav button
- Frontend: show inline error details and settings link in channel test modal
- Frontend: parse error codes from both SSE and non-streaming API responses
- i18n: remove redundant "Settings" suffix from setting tab translations (en/fr/ru/ja/vi)
- i18n: update "Group & Model Pricing" translations across all locales
2026-04-11 17:19:38 +08:00
CaIon 040e8c1da8 feat: replace quota input with amount-first UI and atomic quota adjustment
- Refactor token, redemption, and user quota inputs to prioritize monetary
  amount entry, with raw quota input collapsed by default
- Add atomic quota adjustment modal for users with add/subtract/override modes,
  bypassing batch update queue for immediate DB consistency
- Make user quota fields readonly in edit form; all modifications go through
  the dedicated adjust-quota modal via POST /api/user/manage
- Add DecreaseUserQuota `db` parameter for direct DB writes, matching
  IncreaseUserQuota behavior
- Support negative quota display in amount conversion helpers
- Add i18n keys for all new UI strings across all locales
2026-04-09 22:44:53 +08:00
Seefs 0664bb3f65 Merge pull request #4076 from seefs001/ci/add-pr-check
ci: refine PR template and add PR submission checks
2026-04-09 14:35:38 +08:00
Seefs c7cf20391e fix: document render (#4153) 2026-04-09 14:35:31 +08:00
Calcium-Ion b07f0b9626 Merge pull request #4154 from seefs001/feature/vllm-extensions-params
feat: fill in some custom fields for vllm-omini
2026-04-09 14:35:05 +08:00
Calcium-Ion 53cf37a469 fix(ali): accept string usage values in task polling (#4155) 2026-04-09 14:34:44 +08:00
Seefs 3bda738ec1 fix: prefer explicit pricing for compact models (#4156) 2026-04-09 14:34:14 +08:00
NyaMisty 160cb28572 fix(zhipu_4v): use correct endpoint for coding plan image generation (#4146) 2026-04-09 14:33:48 +08:00
Seefs 274307b0a9 fix(ali): accept string usage values in task polling 2026-04-09 12:48:17 +08:00
Seefs a19a63b98c feat: fill in some custom fields for vllm-omini. 2026-04-09 12:41:51 +08:00
CaIon 78e4cb3cad feat(web): redesign group ratio rules with collapsible grouped layout
Rewrite GroupGroupRatioRules and GroupSpecialUsableRules to group rules
by user group in collapsible sections instead of a flat table. Default
collapsed to reduce visual clutter when many rules exist. Fix i18n
translations for ja, zh-TW with proper native text; add missing keys.
2026-04-08 17:09:42 +08:00
forsakenyang c734db34e8 feat: add minimax image generation relay support (#4103) 2026-04-08 16:57:44 +08:00
星野梦月 a18ea3cc16 feat: 支持强制使用 AUTH LOGIN 以解决 outlook 等邮箱的发件问题 (#4112)
* feat: 支持强制使用 AUTH LOGIN 以解决 outlook 等邮箱的发件问题

* fix: 修复通过 SSL 发送邮件时绕过 AUTH LOGIN 的问题

* fix: remove redundant branch, delete test file, add i18n translations

- Remove redundant else-if branch in SendEmail since auth is already
  computed via getSMTPAuth()
- Delete option_smtp_auth_test.go as requested
- Add i18n translations for '强制使用 AUTH LOGIN' checkbox
2026-04-08 16:53:10 +08:00
CaIon aafbd78887 feat(dashboard): add copy button next to API link in API info panel
Closes #4058
2026-04-08 16:39:50 +08:00
CaIon 77897a8101 feat(dashboard): enhance chart axes and update sorting logic 2026-04-08 15:57:26 +08:00
Calcium-Ion 9b4ffb0875 Merge pull request #4142 from seefs001/fix/skip_failure_option
fix: 修复 失败后不重试 配置项写到内存被覆盖
2026-04-08 15:45:02 +08:00
CaIon 606a4eee96 feat(dashboard): add admin user analytics and fix chart labels
- Add GET /api/data/users endpoint for user-grouped quota data (admin only)
- Add user consumption ranking (horizontal bar, top 10) and user consumption
  trend (area chart) tabs visible only to admin users
- Fix mislabeled "消耗趋势" tab to "调用趋势" (shows call counts, not quota)
- Add processUserData helper for user ranking and trend data extraction
- Add i18n keys for new tabs across all 7 locales
2026-04-08 15:44:01 +08:00
Calcium-Ion 9ffb85a36b Merge pull request #4068 from feitianbubu/seedance-support-duration
Seedance support duration
2026-04-08 15:01:25 +08:00
Seefs c3b8fa29b2 fix: 修复 失败后不重试 配置项写到内存被覆盖 2026-04-08 14:01:27 +08:00
Calcium-Ion a057eddac1 Merge pull request #4131 from binorxin/add-error-logs
chore: 添加 启用错误日志记录到env配置中
2026-04-08 13:46:18 +08:00
Calcium-Ion 1110403750 Merge pull request #4136 from QuantumNous/dependabot/go_modules/github.com/aws/aws-sdk-go-v2/service/bedrockruntime-1.50.4
chore(deps): bump github.com/aws/aws-sdk-go-v2/service/bedrockruntime from 1.50.0 to 1.50.4
2026-04-08 13:43:34 +08:00
Calcium-Ion 3a2aecbc01 Merge pull request #4123 from bbbugg/fix/enabled-api
fix(pricing): add filtering for pricing based on usable groups
2026-04-08 13:43:02 +08:00
Calcium-Ion 49648d8b80 Merge pull request #4128 from zuiho-kai/fix/claude-stream-usage-overwrite
fix: Claude 流式断流时不再整份覆盖 usage,保留 cache 计费字段
2026-04-08 13:42:39 +08:00
Seefs 59d5aef393 fix: 修复 失败后不重试 配置项写到内存被覆盖 2026-04-08 13:41:31 +08:00
Seefs 48695e0e6f Merge pull request #3350 from goodmorning10/feat/error-boundary
feat: add ErrorBoundary to prevent full-page crashes
2026-04-08 12:21:11 +08:00
Seefs e96ca77542 Merge branch 'main' into feat/error-boundary 2026-04-08 12:20:50 +08:00
Seefs 1ad2557668 Merge pull request #3488 from clansty/feature/channel-affinity-include-model
feat: add IncludeModelName option to channel affinity rules
2026-04-08 11:54:31 +08:00
dependabot[bot] ded3bb9cb1 chore(deps): bump github.com/aws/aws-sdk-go-v2/service/bedrockruntime
Bumps [github.com/aws/aws-sdk-go-v2/service/bedrockruntime](https://github.com/aws/aws-sdk-go-v2) from 1.50.0 to 1.50.4.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.50.0...service/ssm/v1.50.4)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/bedrockruntime
  dependency-version: 1.50.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-08 01:23:22 +00:00
CaIon dc83c4af31 refactor(settings): update RatioSetting component to use ModelPricingCombined and adjust tab structure
- Replaced ModelRatioSettings with ModelPricingCombined in the RatioSetting component.
- Updated tab structure to prioritize pricing settings over model settings.
- Removed unused imports for ModelRatioSettings and ModelSettingsVisualEditor.
2026-04-08 01:00:09 +08:00
borx cf1b485389 add 添加 启用错误日志记录到env配置中 2026-04-07 21:12:13 +08:00
Clansty 741aaf4436 fix: wrap scope tag labels with t() for i18n support 2026-04-07 20:00:34 +08:00
zuiho c66636a0c7 fix: 采纳 CodeRabbit 建议,!Done 时也用 fallback 覆盖占位 CompletionTokens
message_start 阶段可能给 CompletionTokens 非零占位值,
只检查 == 0 不够,加上 !Done && fallback > current 条件。
2026-04-07 17:52:11 +08:00
zuiho f7cdc727df fix: Claude 流式断流时不再整份覆盖 usage,保留 cache 计费字段
HandleStreamFinalResponse 在 !Done 时调用 ResponseText2Usage 整份覆盖
claudeInfo.Usage,导致 message_start 已获取的 CacheReadInputTokens、
CacheCreationInputTokens 等字段丢失,prompt 退化为占位值 1。

修复:
- 只补缺失的 CompletionTokens/PromptTokens,保留已有 cache 数据
- PromptTokens 兜底改用 info.GetEstimatePromptTokens()(与其他渠道对齐)

Fixes #4127
2026-04-07 17:41:08 +08:00
bbbugg 07843d7898 fix(pricing): add filtering for pricing based on usable groups 2026-04-07 15:56:28 +08:00
irongit 559c98f261 feat(web): add ErrorBoundary to prevent full-page crashes 2026-04-06 22:32:19 +08:00
Calcium-Ion 960bf9c49e Merge pull request #4114 from RedwindA/fix/4110
feat(token): add batch API for fetching token keys
2026-04-06 19:49:10 +08:00
RedwindA 12a48c620e feat(token): add batch API for fetching token keys
Add new endpoint POST /api/token/batch/keys to fetch multiple
token keys in a single request, improving performance when
exporting or copying multiple tokens.

- Backend: Add GetTokenKeysBatch controller and GetTokenKeysByIds model
- Backend: Add route with CriticalRateLimit and DisableCache middleware
- Frontend: Add fetchTokenKeysBatch helper function
- Frontend: Update useTokensData to use batch API for token export
2026-04-06 19:46:01 +08:00
Calcium-Ion eacc245bad Merge pull request #4106 from HynoR/feat/fix
feat(playground): enhance max_tokens handling and input sanitization
2026-04-06 16:01:28 +08:00
CaIon 03758a4a85 refactor(file-source): unify file source creation and enhance caching mechanisms 2026-04-06 15:54:55 +08:00
CaIon 8fc0eb78e2 feat(billing): enhance task billing process with video input detection and updated pricing logic
- Added `EstimateBilling` function to check for video input in request metadata and return corresponding discount ratios.
- Updated `ModelPriceHelperPerCall` to incorporate new pricing logic based on model ratios and video input.
- Enhanced task billing logs to include model ratio information and adjusted calculations for actual quota based on additional multipliers.
- Introduced `renderTaskBillingProcess` to improve rendering of task billing information in the UI.
2026-04-06 15:54:55 +08:00
HynoR 427fb7eaf6 refactor(playground): remove playgroundMaxTokens helper and update input handling
- Deleted the `playgroundMaxTokens` helper functions and their associated tests.
- Updated `loadConfig` and `usePlaygroundState` to handle `max_tokens` directly without sanitization.
- Simplified input handling in `usePlaygroundState` to directly set values without normalization.
2026-04-06 00:48:06 +08:00
HynoR 4cd0e3651d feat(playground): enhance max_tokens handling and input sanitization
- Introduced `sanitizePlaygroundInputs` to normalize `max_tokens` input values.
- Updated `loadConfig` to utilize the new sanitization function.
- Replaced `Input` with `InputNumber` for `max_tokens` in `ParameterControl` for better user experience.
- Modified API payload building logic to handle `max_tokens` more robustly.
- Added tests for new helper functions in `playgroundMaxTokens.js` to ensure correct behavior.
2026-04-06 00:40:08 +08:00
Calcium-Ion 677d02f2ab Merge pull request #4097 from QuantumNous/dependabot/npm_and_yarn/electron/lodash-4.18.1
chore(deps-dev): bump lodash from 4.17.23 to 4.18.1 in /electron
2026-04-05 15:30:22 +08:00
dependabot[bot] c583382af7 chore(deps-dev): bump lodash from 4.17.23 to 4.18.1 in /electron
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-05 06:04:44 +00:00
Calcium-Ion b1950655e7 Merge pull request #3523 from QuantumNous/dependabot/npm_and_yarn/electron/xmldom/xmldom-0.8.12
chore(deps-dev): bump @xmldom/xmldom from 0.8.11 to 0.8.12 in /electron
2026-04-05 14:04:19 +08:00
Calcium-Ion 22cbbcab4c Merge pull request #4080 from QuantumNous/dependabot/npm_and_yarn/electron/electron-39.8.5
chore(deps-dev): bump electron from 35.7.5 to 39.8.5 in /electron
2026-04-05 14:03:40 +08:00
Calcium-Ion 873067f7d1 Merge pull request #4090 from seefs001/fix/claude2openai-usage
fix: emit claude message_delta for usage-only final stream chunk
2026-04-04 21:09:46 +08:00
Seefs 82c2008d2c fix: emit claude message_delta for usage-only final stream chunk 2026-04-04 20:21:13 +08:00
Seefs 495e4f5e17 Merge pull request #4087 from D26FORWARD/fix/gemini-native-stream-detection
fix(gemini): detect streaming from URL path :streamGenerateContent
2026-04-04 19:04:32 +08:00
D26FORWARD 23fde25b15 fix(gemini): detect streaming from URL path :streamGenerateContent
Google's native Gemini API uses the URL action :streamGenerateContent
to indicate streaming intent, not just the ?alt=sse query parameter.
The current IsStream() only checks c.Query("alt") == "sse", causing
all :streamGenerateContent requests (without ?alt=sse) to be treated
as non-streaming.

This adds a strings.Contains check for "streamGenerateContent" in the
request URL path, so both streaming indicators are recognized.
2026-04-04 16:11:13 +08:00
dependabot[bot] ac90d9f185 chore(deps-dev): bump electron from 35.7.5 to 39.8.5 in /electron
Bumps [electron](https://github.com/electron/electron) from 35.7.5 to 39.8.5.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v35.7.5...v39.8.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-03 22:21:15 +00:00
CaIon bb5b9eaca2 fix(relay-claude): set TopP to nil in Claude request to align with API requirements 2026-04-03 20:18:28 +08:00
feitianbubu b713e277cd feat: metadata correct parse 2026-04-03 15:28:08 +08:00
feitianbubu 08a5243bbc feat: TaskSubmitReq support Duration 2026-04-03 15:00:23 +08:00
CaIon c9611c493f feat(usage-logs): enhance stream status display with error tooltip 2026-04-02 22:27:11 +08:00
CaIon 1baf4a6337 feat(i18n): update localization files with new currency display options and descriptions
Added translations for custom currency display settings across multiple languages, including descriptions for quota display types and exchange rates. This enhances user experience by providing clear information on how quotas are displayed and calculated in different currencies.
2026-04-02 21:56:36 +08:00
Calcium-Ion 9816ad87e3 feat: add HEIC/HEIF image format support for Gemini channel (#4049)
* feat: add HEIC/HEIF image format support

Add detection, MIME type mapping, and dimension parsing for HEIC/HEIF
images via ISOBMFF ftyp brand inspection and ispe box parsing. Update
Gemini relay to accept these formats and refactor getImageConfig to
properly retry decoders using buffered data.

* fix: handle ISOBMFF extended sizes in HEIF dimension parser

parseHEIFDimensions now correctly handles boxSize==1 (64-bit extended
size) and boxSize==0 (box-to-EOF), preventing the parser from breaking
out of the loop when encountering these valid ISOBMFF box headers
before reaching the meta box.
2026-04-02 21:32:42 +08:00
CaIon 50249f581c refactor(middleware): enhance performance error messages
Updated performance checks to provide more detailed error messages, including current usage and thresholds for CPU, memory, and disk. Additionally. Updated localization files to reflect changes in retry options across multiple languages.
2026-04-02 21:31:35 +08:00
Calcium-Ion 0193018af6 Merge pull request #4042 from feitianbubu/pr/fe9713dcbf8795e127fbea2fcb1f3011da86ad54
新增seedance2.0视频接口支持
2026-04-02 21:30:31 +08:00
RedwindA f449e06b9d fix: handle ISOBMFF extended sizes in HEIF dimension parser
parseHEIFDimensions now correctly handles boxSize==1 (64-bit extended
size) and boxSize==0 (box-to-EOF), preventing the parser from breaking
out of the loop when encountering these valid ISOBMFF box headers
before reaching the meta box.
2026-04-02 17:01:21 +08:00
RedwindA 79527c0ab1 feat: add HEIC/HEIF image format support
Add detection, MIME type mapping, and dimension parsing for HEIC/HEIF
images via ISOBMFF ftyp brand inspection and ispe box parsing. Update
Gemini relay to accept these formats and refactor getImageConfig to
properly retry decoders using buffered data.
2026-04-02 16:40:45 +08:00
Calcium-Ion 41cd051ea9 Merge pull request #3505 from seefs001/fix/claude-media-support
fix: add basic inline file support for Claude relay
2026-04-02 13:29:21 +08:00
Seefs c04f82bfb5 TODO: fix chat -> messages file type 2026-04-02 13:16:58 +08:00
feitianbubu dafc7618c3 feat: add seedance fail reason 2026-04-02 12:26:44 +08:00
feitianbubu 22692b3f87 feat: seedance support seconds 2026-04-02 12:26:37 +08:00
feitianbubu d36e892905 fix: seedance only one text 2026-04-02 12:26:33 +08:00
feitianbubu 3cd1ba4673 fix: seedance metadata override prompt 2026-04-02 12:26:27 +08:00
feitianbubu b7c0f754ad feat: add seedance2.0 video api 2026-04-02 12:24:24 +08:00
CaIon a706f00287 feat(EditChannelModal): persist advanced settings state in local storage
Added functionality to save and restore the state of advanced settings in the EditChannelModal using local storage. This enhancement allows users to maintain their preferences when editing channels, improving the overall user experience.
2026-04-02 00:17:21 +08:00
Calcium-Ion 7efb1922fe Merge pull request #3526 from feitianbubu/pr/e560265b6e57aa7b95bc98cb53397ef0a3082d9d
支持wan2.7生图-wan2.7-image
2026-04-02 00:15:04 +08:00
Calcium-Ion 89fe99f3bd Merge pull request #3512 from imlhb/patch-2
fix: prevent double-counting of image count n in billing
2026-04-02 00:14:39 +08:00
feitianbubu e5b5331d3b feat: wan 2.7 support N for gen images number 2026-04-01 17:39:50 +08:00
feitianbubu 18373c6eac feat: add wan 2.7 2026-04-01 17:39:11 +08:00
Calcium-Ion 5b47011e08 Merge pull request #3450 from QuantumNous/dependabot/npm_and_yarn/electron/picomatch-4.0.4
chore(deps-dev): bump picomatch from 4.0.3 to 4.0.4 in /electron
2026-04-01 14:35:30 +08:00
dependabot[bot] 314ae40820 chore(deps-dev): bump @xmldom/xmldom from 0.8.11 to 0.8.12 in /electron
Bumps [@xmldom/xmldom](https://github.com/xmldom/xmldom) from 0.8.11 to 0.8.12.
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.8.11...0.8.12)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-version: 0.8.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 04:51:17 +00:00
CaIon ab99c30884 fix: move image count n to OtherRatio to prevent double-counting
The previous commit commented out AddOtherRatio("n") in the Ali
adaptor to fix double-counting but this could cause billing evasion
when n is specified via extra["parameters"] instead of request.N.

Root cause: ImagePriceRatio in GetTokenCountMeta() already included
n, AND channel adaptors added OtherRatio("n"), resulting in n²
billing.

Proper fix:
- Remove n from ImagePriceRatio (keep sizeRatio * qualityRatio only)
- In ImageHelper, add default OtherRatio("n") when adaptor hasn't
  set one; set fallback tokens to 1 (base unit)
- Restore Ali adaptor's AddOtherRatio("n") — it uses actual upstream
  parameters/response count, preventing billing evasion
2026-03-31 23:58:10 +08:00
CaIon 670abee2f0 fix(EditChannelModal): enhance clipboard handling with error checks
Added checks to ensure clipboard functionality is available before attempting to read from it. Improved error handling during clipboard read operations to prevent unhandled exceptions.
2026-03-31 21:42:36 +08:00
CaIon 8bb9a42f68 feat: add clipboard magic string for quick channel creation from token copy
When copying a token, users can now choose "Copy Connection String" which
encodes both the API key and server URL as a JSON clipboard payload
(type: newapi_channel_conn). When opening the channel creation form, the
clipboard is auto-detected and a banner offers to fill key + base_url,
eliminating repeated tab-switching when connecting to another new-api instance.
2026-03-31 19:39:23 +08:00
CaIon d22f889e5d fix(xAI): set MaxTokens to nil when MaxCompletionTokens is 0 for grok-3-mini model 2026-03-31 19:16:16 +08:00
Calcium-Ion 3734059da7 Merge pull request #3462 from DaZuiZui/main
docs(zh-TW): fix missing content and add partner logo
2026-03-31 19:02:15 +08:00
Calcium-Ion 26ce873f8b Merge pull request #3474 from wans10/main
fix(dashboard): 修复消耗分布图表悬浮时滚动条闪烁
2026-03-31 18:57:16 +08:00
CaIon e099117c61 refactor: use POST for account binding endpoints and normalize reset responses
- Switch /api/oauth/email/bind and /api/oauth/wechat/bind from GET to
  POST with JSON body for better REST semantics
- Normalize password reset endpoint to return consistent responses
- Apply url.QueryEscape to WeChat code parameter for robustness
2026-03-31 18:44:40 +08:00
CaIon 310d618a16 style: enhance footer layout and add custom class for styling
- Refactor Footer component to use a semantic <footer> element for better accessibility.
- Update CSS to include a new class for the custom footer, allowing for relative positioning.
- Adjust layout to improve responsiveness and visual alignment of footer content.
2026-03-31 18:41:44 +08:00
CaIon 20399d3c8f fix: harden SSRF protection for unauthenticated and user-level endpoints
- Add ValidateURLWithFetchSetting check before fetching MJ image URLs
  in RelayMidjourneyImage (unauthenticated endpoint)
- Add ValidateURLWithFetchSetting check before fetching video URLs
  in VideoProxy (upstream-controlled URL)
- Enable ApplyIPFilterForDomain by default to prevent DNS rebinding
  bypass of SSRF protection
- Elevate FetchModels endpoint from AdminAuth to RootAuth
- Update frontend: mark domain IP filtering as recommended, update
  description and i18n translations (zh-CN/zh-TW/en/fr/ja/ru/vi)
2026-03-31 17:57:47 +08:00
刘泓宾 53aeee4ff7 Comment out price data adjustment logic
Comment out code that modifies price data based on image count.
测试发现,如果是接入阿里百炼平台的qwen-image-2.0系列模型,这边计费的时候会出现 0.2*n*倍率*n的情况,最前面的0.2*n会直接显示为模型价格。
例如:
日志详情	模型价格 $0.600000,专属倍率 0.3
其他详情	大小 1080*1920, 品质 standard, 生成数量 3, 其他倍率 n: 3.000000
计费过程	模型价格:$0.600000 * 专属倍率:0.3 = $0.180000
2026-03-31 17:12:06 +08:00
Seefs 263b9bc695 fix: add basic inline file support for Claude relay 2026-03-30 19:37:21 +08:00
Clansty 116e0b8f1c feat: add include_model_name UI switch to channel affinity settings 2026-03-29 02:48:37 +08:00
Clansty 70560d5371 feat: add IncludeModelName option to channel affinity rules for per-model affinity tracking 2026-03-29 02:22:24 +08:00
wans10 b2dd4acc9f fix(dashboard): 修复消耗分布图表悬浮时滚动条闪烁 2026-03-28 12:14:22 +08:00
哇塞大嘴好帥 4e492b26f6 Update README.zh_TW.md 2026-03-27 17:34:28 +08:00
哇塞大嘴好帥 82b750398c Update README.zh_TW.md 2026-03-27 17:15:12 +08:00
dependabot[bot] 814a3f5124 chore(deps-dev): bump picomatch from 4.0.3 to 4.0.4 in /electron
Bumps [picomatch](https://github.com/micromatch/picomatch) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 07:58:57 +00:00
136 changed files with 8124 additions and 3174 deletions
+2
View File
@@ -19,6 +19,8 @@
# HOSTNAME=your-hostname
# 数据库相关配置
# 启用错误日志记录
# ERROR_LOG_ENABLED=true
# 数据库连接字符串
# SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true
# 日志数据库连接字符串
+28
View File
@@ -0,0 +1,28 @@
# ⚠️ 提交说明 / PR Notice
> [!IMPORTANT]
>
> - 请提供**人工撰写**的简洁摘要,避免直接粘贴未经整理的 AI 输出。
## 📝 变更描述 / Description
(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)
## 🚀 变更类型 / Type of change
- [ ] 🐛 Bug 修复 (Bug fix) - *请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug*
- [ ] ✨ 新功能 (New feature) - *重大特性建议先通过 Issue 沟通*
- [ ] ⚡ 性能优化 / 重构 (Refactor)
- [ ] 📝 文档更新 (Documentation)
## 🔗 关联任务 / Related Issue
- Closes # (如有)
## ✅ 提交前检查项 / Checklist
- [ ] **人工确认:** 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。
- [ ] **Bug fix 说明:** 若此 PR 标记为 `Bug fix`,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
- [ ] **变更理解:** 我已理解这些更改的工作原理及可能影响。
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
## 📸 运行证明 / Proof of Work
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
@@ -1,29 +0,0 @@
# ⚠️ 提交警告 / PR Warning
> **请注意:** 请提供**人工撰写**的简洁摘要。包含大量 AI 灌水内容、逻辑混乱或无视模版的 PR **可能会被无视或直接关闭**。
---
## 💡 沟通提示 / Pre-submission
> **重大功能变更?** 请先提交 Issue 交流,避免无效劳动。
## 📝 变更描述 / Description
(简述:做了什么?为什么这样改能生效?你必须理解代码逻辑,禁止粘贴 AI 废话)
## 🚀 变更类型 / Type of change
- [ ] 🐛 Bug 修复 (Bug fix)
- [ ] ✨ 新功能 (New feature) - *重大特性建议先 Issue 沟通*
- [ ] ⚡ 性能优化 / 重构 (Refactor)
- [ ] 📝 文档更新 (Documentation)
## 🔗 关联任务 / Related Issue
- Closes # (如有)
## ✅ 提交前检查项 / Checklist
- [ ] **人工确认:** 我已亲自撰写此描述,去除了 AI 原始输出的冗余。
- [ ] **深度理解:** 我已**完全理解**这些更改的工作原理及潜在影响。
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
- [ ] **本地验证:** 已在本地运行并通过了测试或手动验证。
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
## 📸 运行证明 / Proof of Work
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
+33
View File
@@ -0,0 +1,33 @@
name: PR Check
permissions:
contents: read
issues: read
pull-requests: read
on:
pull_request_target:
types: [opened, reopened]
jobs:
pr-quality:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0.2.1
with:
max-failures: 4
require-description: true
# require-linked-issue: false
blocked-terms: |
🤖 Generated with Claude Code
require-pr-template: true
strict-pr-template-sections: "✅ 提交前检查项 / Checklist"
detect-spam-usernames: true
min-account-age: 30
failure-add-pr-labels: "pr-check-failed"
failure-pr-message: "感谢您的提交。由于该 PR 未遵循我们的贡献模板,且被识别为缺乏人工参与的纯 AI 生成内容 (AI Slop),我们将先予以关闭。我们更欢迎经过人工审核、验证并带有个人思考的贡献。如果您认为这其中存在误解,请回复告知。/ Thank you for your submission. This PR has been closed because it does not follow our contribution template and has been identified as purely AI-generated content (AI Slop) without meaningful human involvement. We prioritize contributions that are human-verified and reflect individual effort. If you believe this is a mistake, please let us know by replying to this comment."
close-pr: true
+2
View File
@@ -29,3 +29,5 @@ data/
.gomodcache/
.gocache-temp
.gopath
token_estimator_test.go
+11 -8
View File
@@ -70,17 +70,20 @@
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank">
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
</a>
<a href="https://bda.pku.edu.cn/" target="_blank">
</a><!--
--><a href="https://github.com/iOfficeAI/AionUi/" target="_blank">
<img src="./docs/images/aionui.png" alt="Aion UI" height="80" />
</a><!--
--><a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="北京大學" height="80" />
</a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
</a><!--
--><a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="80" />
</a>
<a href="https://www.aliyun.com/" target="_blank">
</a><!--
--><a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="阿里雲" height="80" />
</a>
<a href="https://io.net/" target="_blank">
</a><!--
--><a href="https://io.net/" target="_blank">
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
</a>
</p>
+1
View File
@@ -80,6 +80,7 @@ var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true}
var SMTPServer = ""
var SMTPPort = 587
var SMTPSSLEnabled = false
var SMTPForceAuthLogin = false
var SMTPAccount = ""
var SMTPFrom = ""
var SMTPToken = ""
+15 -4
View File
@@ -19,6 +19,20 @@ func generateMessageID() (string, error) {
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil
}
func shouldUseSMTPLoginAuth() bool {
if SMTPForceAuthLogin {
return true
}
return isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer)
}
func getSMTPAuth() smtp.Auth {
if shouldUseSMTPLoginAuth() {
return LoginAuth(SMTPAccount, SMTPToken)
}
return smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
}
func SendEmail(subject string, receiver string, content string) error {
if SMTPFrom == "" { // for compatibility
SMTPFrom = SMTPAccount
@@ -38,7 +52,7 @@ func SendEmail(subject string, receiver string, content string) error {
"Message-ID: %s\r\n"+ // 添加 Message-ID 头
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content))
auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
auth := getSMTPAuth()
addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
to := strings.Split(receiver, ";")
var err error
@@ -80,9 +94,6 @@ func SendEmail(subject string, receiver string, content string) error {
if err != nil {
return err
}
} else if isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer) {
auth = LoginAuth(SMTPAccount, SMTPToken)
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
} else {
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
}
+12 -6
View File
@@ -150,6 +150,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
}
}
cache.WriteContext(c)
c.Set("id", 1)
//c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
c.Request.Header.Set("Content-Type", "application/json")
@@ -274,7 +275,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeModelPriceError),
newAPIError: types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest)),
}
}
@@ -756,11 +757,15 @@ func TestChannel(c *gin.Context) {
tik := time.Now()
result := testChannel(channel, testModel, endpointType, isStream)
if result.localErr != nil {
c.JSON(http.StatusOK, gin.H{
resp := gin.H{
"success": false,
"message": result.localErr.Error(),
"time": 0.0,
})
}
if result.newAPIError != nil {
resp["error_code"] = result.newAPIError.GetErrorCode()
}
c.JSON(http.StatusOK, resp)
return
}
tok := time.Now()
@@ -769,9 +774,10 @@ func TestChannel(c *gin.Context) {
consumedTime := float64(milliseconds) / 1000.0
if result.newAPIError != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": result.newAPIError.Error(),
"time": consumedTime,
"success": false,
"message": result.newAPIError.Error(),
"time": consumedTime,
"error_code": result.newAPIError.GetErrorCode(),
})
return
}
+14 -21
View File
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
@@ -116,7 +117,6 @@ func GetStatus(c *gin.Context) {
"user_agreement_enabled": legalSetting.UserAgreement != "",
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
"_qn": "new-api",
}
// 根据启用状态注入可选内容
@@ -308,31 +308,24 @@ func SendPasswordResetEmail(c *gin.Context) {
})
return
}
if !model.IsEmailAlreadyTaken(email) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该邮箱地址未注册",
})
return
}
code := common.GenerateVerificationCode(0)
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
"<p>如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> %s </p>"+
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
common.ApiError(c, err)
return
if model.IsEmailAlreadyTaken(email) {
code := common.GenerateVerificationCode(0)
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
"<p>如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> %s </p>"+
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("failed to send password reset email to %s: %s", email, err.Error()))
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
type PasswordResetRequest struct {
+27 -1
View File
@@ -1,6 +1,7 @@
package controller
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -8,6 +9,30 @@ import (
"github.com/gin-gonic/gin"
)
func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing {
if len(pricing) == 0 {
return pricing
}
if len(usableGroup) == 0 {
return []model.Pricing{}
}
filtered := make([]model.Pricing, 0, len(pricing))
for _, item := range pricing {
if common.StringsContains(item.EnableGroup, "all") {
filtered = append(filtered, item)
continue
}
for _, group := range item.EnableGroup {
if _, ok := usableGroup[group]; ok {
filtered = append(filtered, item)
break
}
}
}
return filtered
}
func GetPricing(c *gin.Context) {
pricing := model.GetPricing()
userId, exists := c.Get("id")
@@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) {
}
usableGroup = service.GetUserUsableGroups(group)
pricing = filterPricingByUsableGroups(pricing, usableGroup)
// check groupRatio contains usableGroup
for group := range ratio_setting.GetGroupRatioCopy() {
if _, ok := usableGroup[group]; !ok {
@@ -46,7 +72,7 @@ func GetPricing(c *gin.Context) {
"usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
"auto_groups": service.GetUserAutoGroup(group),
"_": "a42d372ccf0b5dd13ecf71203521f9d2",
"pricing_version": "a42d372ccf0b5dd13ecf71203521f9d2",
})
}
+2 -2
View File
@@ -151,7 +151,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeModelPriceError)
newAPIError = types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest))
return
}
@@ -581,7 +581,7 @@ func RelayTask(c *gin.Context) {
ModelRatio: relayInfo.PriceData.ModelRatio,
OtherRatios: relayInfo.PriceData.OtherRatios,
OriginModelName: relayInfo.OriginModelName,
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName),
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
}
task.Quota = result.Quota
task.Data = result.TaskData
+23
View File
@@ -334,3 +334,26 @@ func DeleteTokenBatch(c *gin.Context) {
"data": count,
})
}
func GetTokenKeysBatch(c *gin.Context) {
tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
if len(tokenBatch.Ids) > 100 {
common.ApiErrorI18n(c, i18n.MsgBatchTooMany, map[string]any{"Max": 100})
return
}
userId := c.GetInt("id")
tokens, err := model.GetTokenKeysByIds(tokenBatch.Ids, userId)
if err != nil {
common.ApiError(c, err)
return
}
keysMap := make(map[int]string)
for _, t := range tokens {
keysMap[t.Id] = t.GetFullKey()
}
common.ApiSuccess(c, gin.H{"keys": keysMap})
}
+15
View File
@@ -27,6 +27,21 @@ func GetAllQuotaDates(c *gin.Context) {
return
}
func GetQuotaDatesByUser(c *gin.Context) {
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": dates,
})
}
func GetUserQuotaDates(c *gin.Context) {
userId := c.GetInt("id")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
+64 -9
View File
@@ -52,10 +52,15 @@ func Login(c *gin.Context) {
}
err = user.ValidateAndFill()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": err.Error(),
"success": false,
})
switch {
case errors.Is(err, model.ErrDatabase):
common.SysLog(fmt.Sprintf("Login database error for user %s: %v", username, err))
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
case errors.Is(err, model.ErrUserEmptyCredentials):
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
default:
common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError)
}
return
}
@@ -572,9 +577,6 @@ func UpdateUser(c *gin.Context) {
common.ApiError(c, err)
return
}
if originUser.Quota != updatedUser.Quota {
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota)))
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -841,6 +843,8 @@ func CreateUser(c *gin.Context) {
type ManageRequest struct {
Id int `json:"id"`
Action string `json:"action"`
Value int `json:"value"`
Mode string `json:"mode"`
}
// ManageUser Only admin user can do this
@@ -907,6 +911,47 @@ func ManageUser(c *gin.Context) {
return
}
user.Role = common.RoleCommonUser
case "add_quota":
switch req.Mode {
case "add":
if req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
return
}
if err := model.IncreaseUserQuota(user.Id, req.Value, true); err != nil {
common.ApiError(c, err)
return
}
model.RecordLog(user.Id, model.LogTypeManage,
fmt.Sprintf("管理员增加用户额度 %s", logger.LogQuota(req.Value)))
case "subtract":
if req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
return
}
if err := model.DecreaseUserQuota(user.Id, req.Value, true); err != nil {
common.ApiError(c, err)
return
}
model.RecordLog(user.Id, model.LogTypeManage,
fmt.Sprintf("管理员减少用户额度 %s", logger.LogQuota(req.Value)))
case "override":
oldQuota := user.Quota
if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil {
common.ApiError(c, err)
return
}
model.RecordLog(user.Id, model.LogTypeManage,
fmt.Sprintf("管理员覆盖用户额度从 %s 为 %s", logger.LogQuota(oldQuota), logger.LogQuota(req.Value)))
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
if err := user.Update(false); err != nil {
@@ -925,9 +970,19 @@ func ManageUser(c *gin.Context) {
return
}
type emailBindRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
func EmailBind(c *gin.Context) {
email := c.Query("email")
code := c.Query("code")
var req emailBindRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
common.ApiError(c, errors.New("invalid request body"))
return
}
email := req.Email
code := req.Code
if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) {
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
return
+9
View File
@@ -10,10 +10,12 @@ import (
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
)
@@ -127,6 +129,13 @@ func VideoProxy(c *gin.Context) {
return
}
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(videoURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL blocked for task %s: %v", taskID, err))
videoProxyError(c, http.StatusForbidden, "server_error", fmt.Sprintf("request blocked: %v", err))
return
}
req.URL, err = url.Parse(videoURL)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", videoURL, err.Error()))
+15 -2
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
@@ -25,7 +26,7 @@ func getWeChatIdByCode(code string) (string, error) {
if code == "" {
return "", errors.New("无效的参数")
}
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, code), nil)
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, url.QueryEscape(code)), nil)
if err != nil {
return "", err
}
@@ -121,6 +122,10 @@ func WeChatAuth(c *gin.Context) {
setupLogin(&user, c)
}
type wechatBindRequest struct {
Code string `json:"code"`
}
func WeChatBind(c *gin.Context) {
if !common.WeChatAuthEnabled {
c.JSON(http.StatusOK, gin.H{
@@ -129,7 +134,15 @@ func WeChatBind(c *gin.Context) {
})
return
}
code := c.Query("code")
var req wechatBindRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的请求",
})
return
}
code := req.Code
wechatId, err := getWeChatIdByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
+10
View File
@@ -18,6 +18,16 @@ type AudioRequest struct {
Speed *float64 `json:"speed,omitempty"`
StreamFormat string `json:"stream_format,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
// vllm-omini
TaskType json.RawMessage `json:"task_type,omitempty"`
Language json.RawMessage `json:"language,omitempty"`
RefAudio json.RawMessage `json:"ref_audio,omitempty"`
RefText json.RawMessage `json:"ref_text,omitempty"`
XVectorOnlyMode json.RawMessage `json:"x_vector_only_mode,omitempty"`
MaxNewTokens json.RawMessage `json:"max_new_tokens,omitempty"`
InitialCodecChunkFrames json.RawMessage `json:"initial_codec_chunk_frames,omitempty"`
// TODOensure that the logic remains correct after the stream is started.
//Stream json.RawMessage `json:"stream,omitempty"`
}
func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {
+24 -30
View File
@@ -98,6 +98,20 @@ func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
return mediaContent
}
func (m *ClaudeMediaMessage) ToFileSource() types.FileSource {
if m.Source == nil {
return nil
}
data := m.Source.Url
if data == "" {
data = common.Interface2String(m.Source.Data)
}
if data == "" {
return nil
}
return types.NewFileSourceFromData(data, m.Source.MediaType)
}
type ClaudeMessageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type,omitempty"`
@@ -223,14 +237,6 @@ type OutputConfigForEffort struct {
Effort string `json:"effort,omitempty"`
}
// createClaudeFileSource 根据数据内容创建正确类型的 FileSource
func createClaudeFileSource(data string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, "")
}
func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
maxTokens := 0
if c.MaxTokens != nil {
@@ -258,17 +264,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
case "text":
texts = append(texts, media.GetText())
case "image":
if media.Source != nil {
data := media.Source.Url
if data == "" {
data = common.Interface2String(media.Source.Data)
}
if data != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createClaudeFileSource(data),
})
}
if source := media.ToFileSource(); source != nil {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: source,
})
}
}
}
@@ -293,17 +293,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
case "text":
texts = append(texts, media.GetText())
case "image":
if media.Source != nil {
data := media.Source.Url
if data == "" {
data = common.Interface2String(media.Source.Data)
}
if data != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createClaudeFileSource(data),
})
}
if source := media.ToFileSource(); source != nil {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: source,
})
}
case "tool_use":
if media.Name != "" {
+13 -11
View File
@@ -64,14 +64,6 @@ type LatLng struct {
Longitude *float64 `json:"longitude,omitempty"`
}
// createGeminiFileSource 根据数据内容创建正确类型的 FileSource
func createGeminiFileSource(data string, mimeType string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, mimeType)
}
func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
var files []*types.FileMeta = make([]*types.FileMeta, 0)
@@ -87,9 +79,8 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
if part.Text != "" {
inputTexts = append(inputTexts, part.Text)
}
if part.InlineData != nil && part.InlineData.Data != "" {
if source := part.InlineData.ToFileSource(); source != nil {
mimeType := part.InlineData.MimeType
source := createGeminiFileSource(part.InlineData.Data, mimeType)
var fileType types.FileType
if strings.HasPrefix(mimeType, "image/") {
fileType = types.FileTypeImage
@@ -103,7 +94,6 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
files = append(files, &types.FileMeta{
FileType: fileType,
Source: source,
MimeType: mimeType,
})
}
}
@@ -121,6 +111,11 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool {
if c.Query("alt") == "sse" {
return true
}
// Native Gemini API uses URL action to indicate streaming:
// /v1beta/models/{model}:streamGenerateContent
if strings.Contains(c.Request.URL.Path, "streamGenerateContent") {
return true
}
return false
}
@@ -210,6 +205,13 @@ type GeminiInlineData struct {
Data string `json:"data"`
}
func (d *GeminiInlineData) ToFileSource() types.FileSource {
if d == nil || d.Data == "" {
return nil
}
return types.NewFileSourceFromData(d.Data, d.MimeType)
}
// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType
func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
type Alias GeminiInlineData // Use type alias to avoid recursion
+73
View File
@@ -0,0 +1,73 @@
package dto
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestGeminiChatRequest_IsStream(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
path string
query string
expected bool
}{
{
name: "streamGenerateContent without alt=sse",
path: "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
query: "key=sk-xxx",
expected: true,
},
{
name: "streamGenerateContent with alt=sse",
path: "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
query: "alt=sse&key=sk-xxx",
expected: true,
},
{
name: "generateContent without alt=sse",
path: "/v1beta/models/gemini-2.0-flash:generateContent",
query: "key=sk-xxx",
expected: false,
},
{
name: "generateContent with alt=sse",
path: "/v1beta/models/gemini-2.0-flash:generateContent",
query: "alt=sse",
expected: true,
},
{
name: "GenerateContent capitalized",
path: "/v1beta/models/gemini-2.0-flash:GenerateContent",
query: "key=sk-xxx",
expected: false,
},
{
name: "embedding path",
path: "/v1beta/models/gemini-2.0-flash:embedContent",
query: "",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
url := tt.path
if tt.query != "" {
url += "?" + tt.query
}
c.Request, _ = http.NewRequest("POST", url, nil)
req := &GeminiChatRequest{}
assert.Equal(t, tt.expected, req.IsStream(c))
})
}
}
+5 -6
View File
@@ -148,15 +148,14 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
}
}
// not support token count for dalle
n := uint(1)
if i.N != nil {
n = *i.N
}
// n is NOT included here; it is handled via OtherRatio("n") in
// image_handler.go (default) or channel adaptors (actual count).
// Including n here caused double-counting for channels that also
// set OtherRatio("n") (e.g. Ali/Bailian).
return &types.TokenCountMeta{
CombineText: i.Prompt,
MaxTokens: 1584,
ImagePriceRatio: sizeRatio * qualityRatio * float64(n),
ImagePriceRatio: sizeRatio * qualityRatio,
}
}
+53 -47
View File
@@ -108,14 +108,6 @@ type GeneralOpenAIRequest struct {
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
}
// createFileSource 根据数据内容创建正确类型的 FileSource
func createFileSource(data string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, "")
}
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
var tokenCountMeta types.TokenCountMeta
var texts = make([]string, 0)
@@ -159,44 +151,24 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
}
arrayContent := message.ParseContent()
for _, m := range arrayContent {
if m.Type == ContentTypeImageURL {
imageUrl := m.GetImageMedia()
if imageUrl != nil && imageUrl.Url != "" {
source := createFileSource(imageUrl.Url)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: source,
Detail: imageUrl.Detail,
})
source := m.ToFileSource()
if source != nil {
meta := &types.FileMeta{Source: source}
switch m.Type {
case ContentTypeImageURL:
meta.FileType = types.FileTypeImage
if img := m.GetImageMedia(); img != nil {
meta.Detail = img.Detail
}
case ContentTypeInputAudio:
meta.FileType = types.FileTypeAudio
case ContentTypeFile:
meta.FileType = types.FileTypeFile
case ContentTypeVideoUrl:
meta.FileType = types.FileTypeVideo
}
} else if m.Type == ContentTypeInputAudio {
inputAudio := m.GetInputAudio()
if inputAudio != nil && inputAudio.Data != "" {
source := createFileSource(inputAudio.Data)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeAudio,
Source: source,
})
}
} else if m.Type == ContentTypeFile {
file := m.GetFile()
if file != nil && file.FileData != "" {
source := createFileSource(file.FileData)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeFile,
Source: source,
})
}
} else if m.Type == ContentTypeVideoUrl {
videoUrl := m.GetVideoUrl()
if videoUrl != nil && videoUrl.Url != "" {
source := createFileSource(videoUrl.Url)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeVideo,
Source: source,
})
}
} else {
fileMeta = append(fileMeta, meta)
} else if m.Type == ContentTypeText {
texts = append(texts, m.Text)
}
}
@@ -391,6 +363,40 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl {
return nil
}
func (m *MediaContent) ToFileSource() types.FileSource {
switch m.Type {
case ContentTypeImageURL:
img := m.GetImageMedia()
if img == nil || img.Url == "" {
return nil
}
return types.NewFileSourceFromData(img.Url, img.MimeType)
case ContentTypeInputAudio:
audio := m.GetInputAudio()
if audio == nil || audio.Data == "" {
return nil
}
mimeType := ""
if audio.Format != "" {
mimeType = "audio/" + audio.Format
}
return types.NewFileSourceFromData(audio.Data, mimeType)
case ContentTypeFile:
file := m.GetFile()
if file == nil || file.FileData == "" {
return nil
}
return types.NewFileSourceFromData(file.FileData, "")
case ContentTypeVideoUrl:
video := m.GetVideoUrl()
if video == nil || video.Url == "" {
return nil
}
return types.NewFileSourceFromData(video.Url, "")
}
return nil
}
type MessageImageUrl struct {
Url string `json:"url"`
Detail string `json:"detail,omitempty"`
@@ -865,7 +871,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
if input.ImageUrl != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createFileSource(input.ImageUrl),
Source: types.NewFileSourceFromData(input.ImageUrl, ""),
Detail: input.Detail,
})
}
@@ -873,7 +879,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
if input.FileUrl != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeFile,
Source: createFileSource(input.FileUrl),
Source: types.NewFileSourceFromData(input.FileUrl, ""),
})
}
} else {
Generated Vendored
+13 -13
View File
@@ -9,7 +9,7 @@
"version": "1.0.0",
"devDependencies": {
"cross-env": "^7.0.3",
"electron": "35.7.5",
"electron": "39.8.5",
"electron-builder": "^26.7.0"
}
},
@@ -777,9 +777,9 @@
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2145,9 +2145,9 @@
}
},
"node_modules/electron": {
"version": "35.7.5",
"resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz",
"integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==",
"version": "39.8.5",
"resolved": "https://registry.npmjs.org/electron/-/electron-39.8.5.tgz",
"integrity": "sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -3279,9 +3279,9 @@
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
@@ -3948,9 +3948,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -25,7 +25,7 @@
},
"devDependencies": {
"cross-env": "^7.0.3",
"electron": "35.7.5",
"electron": "39.8.5",
"electron-builder": "^26.7.0"
},
"build": {
+5 -5
View File
@@ -8,9 +8,9 @@ require (
github.com/abema/go-mp4 v1.4.1
github.com/andybalholm/brotli v1.1.1
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
github.com/aws/aws-sdk-go-v2 v1.41.2
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
github.com/aws/smithy-go v1.24.2
github.com/bytedance/gopkg v0.1.3
github.com/gin-contrib/cors v1.7.2
@@ -63,9 +63,9 @@ require (
require (
github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
+10 -10
View File
@@ -12,18 +12,18 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0 h1:TDKR8ACRw7G+GFaQlhoy6biu+8q6ZtSddQCy9avMdMI=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0/go.mod h1:XlhOh5Ax/lesqN4aZCUgj9vVJed5VoXYHHFYGAlJEwU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+14
View File
@@ -25,6 +25,19 @@ const (
MsgDeleteFailed = "common.delete_failed"
MsgAlreadyExists = "common.already_exists"
MsgNameCannotBeEmpty = "common.name_cannot_be_empty"
MsgBatchTooMany = "common.batch_too_many"
)
// Auth middleware messages
const (
MsgAuthNotLoggedIn = "auth.not_logged_in"
MsgAuthAccessTokenInvalid = "auth.access_token_invalid"
MsgAuthUserInfoInvalid = "auth.user_info_invalid"
MsgAuthUserIdNotProvided = "auth.user_id_not_provided"
MsgAuthUserIdFormatError = "auth.user_id_format_error"
MsgAuthUserIdMismatch = "auth.user_id_mismatch"
MsgAuthUserBanned = "auth.user_banned"
MsgAuthInsufficientPrivilege = "auth.insufficient_privilege"
)
// Token related messages
@@ -100,6 +113,7 @@ const (
MsgUserTelegramIdEmpty = "user.telegram_id_empty"
MsgUserTelegramNotBound = "user.telegram_not_bound"
MsgUserLinuxDOIdEmpty = "user.linux_do_id_empty"
MsgUserQuotaChangeZero = "user.quota_change_zero"
)
// Quota related messages
+13 -1
View File
@@ -2,7 +2,7 @@
# Common messages
common.invalid_params: "Invalid parameters"
common.database_error: "Database error, please try again later"
common.database_error: "Database error, please contact the administrator"
common.retry_later: "Please try again later"
common.generate_failed: "Generation failed"
common.not_found: "Not found"
@@ -21,6 +21,17 @@ common.delete_success: "Deletion successful"
common.delete_failed: "Deletion failed"
common.already_exists: "Already exists"
common.name_cannot_be_empty: "Name cannot be empty"
common.batch_too_many: "Too many items in batch request, maximum is {{.Max}}"
# Auth middleware messages
auth.not_logged_in: "Unauthorized, not logged in and no access token provided"
auth.access_token_invalid: "Unauthorized, invalid access token"
auth.user_info_invalid: "Unauthorized, invalid user info"
auth.user_id_not_provided: "Unauthorized, New-Api-User header not provided"
auth.user_id_format_error: "Unauthorized, New-Api-User header format error"
auth.user_id_mismatch: "Unauthorized, New-Api-User does not match logged in user"
auth.user_banned: "User has been banned"
auth.insufficient_privilege: "Unauthorized, insufficient privileges"
# Token messages
token.name_too_long: "Token name is too long"
@@ -90,6 +101,7 @@ user.wechat_id_empty: "WeChat ID is empty!"
user.telegram_id_empty: "Telegram ID is empty!"
user.telegram_not_bound: "This Telegram account is not bound"
user.linux_do_id_empty: "Linux DO ID is empty!"
user.quota_change_zero: "Quota change amount cannot be zero"
# Quota messages
quota.negative: "Quota cannot be negative!"
+13 -1
View File
@@ -3,7 +3,7 @@
# Common messages
common.invalid_params: "无效的参数"
common.database_error: "数据库错误,请稍后重试"
common.database_error: "数据库出错,请联系管理员"
common.retry_later: "请稍后重试"
common.generate_failed: "生成失败"
common.not_found: "未找到"
@@ -22,6 +22,17 @@ common.delete_success: "删除成功"
common.delete_failed: "删除失败"
common.already_exists: "已存在"
common.name_cannot_be_empty: "名称不能为空"
common.batch_too_many: "批量请求数量过多,最多 {{.Max}} 条"
# Auth middleware messages
auth.not_logged_in: "无权进行此操作,未登录且未提供 access token"
auth.access_token_invalid: "无权进行此操作,access token 无效"
auth.user_info_invalid: "无权进行此操作,用户信息无效"
auth.user_id_not_provided: "无权进行此操作,未提供 New-Api-User"
auth.user_id_format_error: "无权进行此操作,New-Api-User 格式错误"
auth.user_id_mismatch: "无权进行此操作,New-Api-User 与登录用户不匹配"
auth.user_banned: "用户已被封禁"
auth.insufficient_privilege: "无权进行此操作,权限不足"
# Token messages
token.name_too_long: "令牌名称过长"
@@ -91,6 +102,7 @@ user.wechat_id_empty: "WeChat id 为空!"
user.telegram_id_empty: "Telegram id 为空!"
user.telegram_not_bound: "该 Telegram 账户未绑定"
user.linux_do_id_empty: "Linux DO id 为空!"
user.quota_change_zero: "额度变更量不能为0"
# Quota messages
quota.negative: "额度不能为负数!"
+13 -1
View File
@@ -3,7 +3,7 @@
# Common messages
common.invalid_params: "無效的參數"
common.database_error: "資料庫錯誤,請稍後重試"
common.database_error: "資料庫出錯,請聯繫管理員"
common.retry_later: "請稍後重試"
common.generate_failed: "生成失敗"
common.not_found: "未找到"
@@ -22,6 +22,17 @@ common.delete_success: "刪除成功"
common.delete_failed: "刪除失敗"
common.already_exists: "已存在"
common.name_cannot_be_empty: "名稱不能為空"
common.batch_too_many: "批次請求數量過多,最多 {{.Max}} 條"
# Auth middleware messages
auth.not_logged_in: "無權進行此操作,未登入且未提供 access token"
auth.access_token_invalid: "無權進行此操作,access token 無效"
auth.user_info_invalid: "無權進行此操作,使用者資訊無效"
auth.user_id_not_provided: "無權進行此操作,未提供 New-Api-User"
auth.user_id_format_error: "無權進行此操作,New-Api-User 格式錯誤"
auth.user_id_mismatch: "無權進行此操作,New-Api-User 與登入使用者不匹配"
auth.user_banned: "使用者已被封禁"
auth.insufficient_privilege: "無權進行此操作,權限不足"
# Token messages
token.name_too_long: "令牌名稱過長"
@@ -91,6 +102,7 @@ user.wechat_id_empty: "WeChat id 為空!"
user.telegram_id_empty: "Telegram id 為空!"
user.telegram_not_bound: "該 Telegram 帳號未綁定"
user.linux_do_id_empty: "Linux DO id 為空!"
user.quota_change_zero: "額度變更量不能為0"
# Quota messages
quota.negative: "額度不能為負數!"
+57 -20
View File
@@ -1,6 +1,7 @@
package middleware
import (
"errors"
"fmt"
"net"
"net/http"
@@ -9,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
@@ -17,6 +19,7 @@ import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func validUserInfo(username string, role int) bool {
@@ -43,17 +46,33 @@ func authHelper(c *gin.Context, minRole int) {
if accessToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,未登录且未提供 access token",
"message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn),
})
c.Abort()
return
}
user := model.ValidateAccessToken(accessToken)
user, authErr := model.ValidateAccessToken(accessToken)
if authErr != nil {
if errors.Is(authErr, model.ErrDatabase) {
common.SysLog("ValidateAccessToken database error: " + authErr.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
} else {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
})
}
c.Abort()
return
}
if user != nil && user.Username != "" {
if !validUserInfo(user.Username, user.Role) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,用户信息无效",
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
})
c.Abort()
return
@@ -67,7 +86,7 @@ func authHelper(c *gin.Context, minRole int) {
} else {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,access token 无效",
"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
})
c.Abort()
return
@@ -78,7 +97,7 @@ func authHelper(c *gin.Context, minRole int) {
if apiUserIdStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,未提供 New-Api-User",
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdNotProvided),
})
c.Abort()
return
@@ -87,7 +106,7 @@ func authHelper(c *gin.Context, minRole int) {
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,New-Api-User 格式错误",
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdFormatError),
})
c.Abort()
return
@@ -96,7 +115,7 @@ func authHelper(c *gin.Context, minRole int) {
if id != apiUserId {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,New-Api-User 与登录用户不匹配",
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdMismatch),
})
c.Abort()
return
@@ -104,7 +123,7 @@ func authHelper(c *gin.Context, minRole int) {
if status.(int) == common.UserStatusDisabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户已被封禁",
"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
})
c.Abort()
return
@@ -112,7 +131,7 @@ func authHelper(c *gin.Context, minRole int) {
if role.(int) < minRole {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,权限不足",
"message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege),
})
c.Abort()
return
@@ -120,7 +139,7 @@ func authHelper(c *gin.Context, minRole int) {
if !validUserInfo(username.(string), role.(int)) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,用户信息无效",
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
})
c.Abort()
return
@@ -198,7 +217,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
if key == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未提供 Authorization 请求头",
"message": common.TranslateMessage(c, i18n.MsgTokenNotProvided),
})
c.Abort()
return
@@ -212,19 +231,28 @@ func TokenAuthReadOnly() func(c *gin.Context) {
token, err := model.GetTokenByKey(key, false)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无效的令牌",
})
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgTokenInvalid),
})
} else {
common.SysLog("TokenAuthReadOnly GetTokenByKey database error: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
}
c.Abort()
return
}
userCache, err := model.GetUserCache(token.UserId)
if err != nil {
common.SysLog(fmt.Sprintf("TokenAuthReadOnly GetUserCache error for user %d: %v", token.UserId, err))
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": err.Error(),
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
c.Abort()
return
@@ -232,7 +260,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
if userCache.Status != common.UserStatusEnabled {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "用户已被封禁",
"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
})
c.Abort()
return
@@ -309,7 +337,14 @@ func TokenAuth() func(c *gin.Context) {
}
}
if err != nil {
abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error())
if errors.Is(err, model.ErrDatabase) {
common.SysLog("TokenAuth ValidateUserToken database error: " + err.Error())
abortWithOpenAiMessage(c, http.StatusInternalServerError,
common.TranslateMessage(c, i18n.MsgDatabaseError))
} else {
abortWithOpenAiMessage(c, http.StatusUnauthorized,
common.TranslateMessage(c, i18n.MsgTokenInvalid))
}
return
}
@@ -331,12 +366,14 @@ func TokenAuth() func(c *gin.Context) {
userCache, err := model.GetUserCache(token.UserId)
if err != nil {
abortWithOpenAiMessage(c, http.StatusInternalServerError, err.Error())
common.SysLog(fmt.Sprintf("TokenAuth GetUserCache error for user %d: %v", token.UserId, err))
abortWithOpenAiMessage(c, http.StatusInternalServerError,
common.TranslateMessage(c, i18n.MsgDatabaseError))
return
}
userEnabled := userCache.Status == common.UserStatusEnabled
if !userEnabled {
abortWithOpenAiMessage(c, http.StatusForbidden, "用户已被封禁")
abortWithOpenAiMessage(c, http.StatusForbidden, common.TranslateMessage(c, i18n.MsgAuthUserBanned))
return
}
+10 -4
View File
@@ -1,7 +1,7 @@
package middleware
import (
"errors"
"fmt"
"net/http"
"strings"
@@ -48,17 +48,23 @@ func checkSystemPerformance() *types.NewAPIError {
// 检查 CPU
if config.CPUThreshold > 0 && int(status.CPUUsage) > config.CPUThreshold {
return types.NewErrorWithStatusCode(errors.New("system cpu overloaded"), "system_cpu_overloaded", http.StatusServiceUnavailable)
return types.NewErrorWithStatusCode(
fmt.Errorf("system cpu overloaded (current: %.1f%%, threshold: %d%%)", status.CPUUsage, config.CPUThreshold),
"system_cpu_overloaded", http.StatusServiceUnavailable)
}
// 检查内存
if config.MemoryThreshold > 0 && int(status.MemoryUsage) > config.MemoryThreshold {
return types.NewErrorWithStatusCode(errors.New("system memory overloaded"), "system_memory_overloaded", http.StatusServiceUnavailable)
return types.NewErrorWithStatusCode(
fmt.Errorf("system memory overloaded (current: %.1f%%, threshold: %d%%)", status.MemoryUsage, config.MemoryThreshold),
"system_memory_overloaded", http.StatusServiceUnavailable)
}
// 检查磁盘
if config.DiskThreshold > 0 && int(status.DiskUsage) > config.DiskThreshold {
return types.NewErrorWithStatusCode(errors.New("system disk overloaded"), "system_disk_overloaded", http.StatusServiceUnavailable)
return types.NewErrorWithStatusCode(
fmt.Errorf("system disk overloaded (current: %.1f%%, threshold: %d%%)", status.DiskUsage, config.DiskThreshold),
"system_disk_overloaded", http.StatusServiceUnavailable)
}
return nil
+12 -1
View File
@@ -2,14 +2,25 @@ package middleware
import (
"context"
"crypto/sha256"
"encoding/hex"
"runtime/debug"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
var _bp = func() string {
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Path != "" {
h := sha256.Sum256([]byte(bi.Main.Path))
return hex.EncodeToString(h[:4])
}
return common.GetRandomString(8)
}()
func RequestId() func(c *gin.Context) {
return func(c *gin.Context) {
id := common.GetTimeString() + common.GetRandomString(8)
id := common.GetTimeString() + _bp + common.GetRandomString(8)
c.Set(common.RequestIdKey, id)
ctx := context.WithValue(c.Request.Context(), common.RequestIdKey, id)
c.Request = c.Request.WithContext(ctx)
+26
View File
@@ -0,0 +1,26 @@
package model
import "errors"
// Common errors
var (
ErrDatabase = errors.New("database error")
)
// User auth errors
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUserEmptyCredentials = errors.New("empty credentials")
)
// Token auth errors
var (
ErrTokenNotProvided = errors.New("token not provided")
ErrTokenInvalid = errors.New("token invalid")
)
// Redemption errors
var ErrRedeemFailed = errors.New("redeem.failed")
// 2FA errors
var ErrTwoFANotEnabled = errors.New("2fa not enabled")
+4 -1
View File
@@ -62,6 +62,7 @@ func InitOptionMap() {
common.OptionMap["SMTPAccount"] = ""
common.OptionMap["SMTPToken"] = ""
common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled)
common.OptionMap["SMTPForceAuthLogin"] = strconv.FormatBool(common.SMTPForceAuthLogin)
common.OptionMap["Notice"] = ""
common.OptionMap["About"] = ""
common.OptionMap["HomePageContent"] = ""
@@ -233,7 +234,7 @@ func updateOptionMap(key string, value string) (err error) {
common.ImageDownloadPermission = intValue
}
}
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" {
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" || key == "SMTPForceAuthLogin" {
boolValue := value == "true"
switch key {
case "PasswordRegisterEnabled":
@@ -308,6 +309,8 @@ func updateOptionMap(key string, value string) (err error) {
setting.StopOnSensitiveEnabled = boolValue
case "SMTPSSLEnabled":
common.SMTPSSLEnabled = boolValue
case "SMTPForceAuthLogin":
common.SMTPForceAuthLogin = boolValue
case "WorkerAllowHttpImageRequestEnabled":
system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
case "DefaultUseAutoGroup":
-3
View File
@@ -11,9 +11,6 @@ import (
"gorm.io/gorm"
)
// ErrRedeemFailed is returned when redemption fails due to database error
var ErrRedeemFailed = errors.New("redeem.failed")
type Redemption struct {
Id int `json:"id"`
UserId int `json:"user_id"`
+17 -18
View File
@@ -187,19 +187,14 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi
func ValidateUserToken(key string) (token *Token, err error) {
if key == "" {
return nil, errors.New("未提供令牌")
return nil, ErrTokenNotProvided
}
token, err = GetTokenByKey(key, false)
if err == nil {
if token.Status == common.TokenStatusExhausted {
keyPrefix := key[:3]
keySuffix := key[len(key)-3:]
return token, errors.New("该令牌额度已用尽 TokenStatusExhausted[sk-" + keyPrefix + "***" + keySuffix + "]")
} else if token.Status == common.TokenStatusExpired {
return token, errors.New("该令牌已过期")
}
if token.Status != common.TokenStatusEnabled {
return token, errors.New("该令牌状态不可用")
if token.Status == common.TokenStatusExhausted ||
token.Status == common.TokenStatusExpired ||
token.Status != common.TokenStatusEnabled {
return token, ErrTokenInvalid
}
if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() {
if !common.RedisEnabled {
@@ -209,29 +204,25 @@ func ValidateUserToken(key string) (token *Token, err error) {
common.SysLog("failed to update token status" + err.Error())
}
}
return token, errors.New("该令牌已过期")
return token, ErrTokenInvalid
}
if !token.UnlimitedQuota && token.RemainQuota <= 0 {
if !common.RedisEnabled {
// in this case, we can make sure the token is exhausted
token.Status = common.TokenStatusExhausted
err := token.SelectUpdate()
if err != nil {
common.SysLog("failed to update token status" + err.Error())
}
}
keyPrefix := key[:3]
keySuffix := key[len(key)-3:]
return token, fmt.Errorf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota)
return token, ErrTokenInvalid
}
return token, nil
}
common.SysLog("ValidateUserToken: failed to get token: " + err.Error())
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("无效的令牌")
} else {
return nil, errors.New("无效的令牌,数据库查询出错,请联系管理员")
return nil, ErrTokenInvalid
}
return nil, fmt.Errorf("%w: %v", ErrDatabase, err)
}
func GetTokenByIds(id int, userId int) (*Token, error) {
@@ -481,3 +472,11 @@ func BatchDeleteTokens(ids []int, userId int) (int, error) {
return len(tokens), nil
}
func GetTokenKeysByIds(ids []int, userId int) ([]Token, error) {
var tokens []Token
err := DB.Select("id", commonKeyCol).
Where("user_id = ? AND id IN (?)", userId, ids).
Find(&tokens).Error
return tokens, err
}
-2
View File
@@ -10,8 +10,6 @@ import (
"gorm.io/gorm"
)
var ErrTwoFANotEnabled = errors.New("用户未启用2FA")
// TwoFA 用户2FA设置表
type TwoFA struct {
Id int `json:"id" gorm:"primaryKey"`
+10
View File
@@ -115,6 +115,16 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData
return quotaDatas, err
}
func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
var quotaDatas []*QuotaData
err = DB.Table("quota_data").
Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
Where("created_at >= ? and created_at <= ?", startTime, endTime).
Group("username, created_at").
Find(&quotaDatas).Error
return quotaDatas, err
}
func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) {
if username != "" {
return GetQuotaDataByUsername(username, startTime, endTime)
+23 -14
View File
@@ -523,7 +523,6 @@ func (user *User) Edit(updatePassword bool) error {
"username": newUser.Username,
"display_name": newUser.DisplayName,
"group": newUser.Group,
"quota": newUser.Quota,
"remark": newUser.Remark,
}
if updatePassword {
@@ -598,13 +597,19 @@ func (user *User) ValidateAndFill() (err error) {
password := user.Password
username := strings.TrimSpace(user.Username)
if username == "" || password == "" {
return errors.New("用户名或密码为空")
return ErrUserEmptyCredentials
}
// find by username or email
err = DB.Where("username = ? OR email = ?", username, username).First(user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrInvalidCredentials
}
return fmt.Errorf("%w: %v", ErrDatabase, err)
}
// find buy username or email
DB.Where("username = ? OR email = ?", username, username).First(user)
okay := common.ValidatePasswordAndHash(password, user.Password)
if !okay || user.Status != common.UserStatusEnabled {
return errors.New("用户名或密码错误,或用户已被封禁")
return ErrInvalidCredentials
}
return nil
}
@@ -755,16 +760,20 @@ func IsAdmin(userId int) bool {
// return user.Status == common.UserStatusEnabled, nil
//}
func ValidateAccessToken(token string) (user *User) {
func ValidateAccessToken(token string) (*User, error) {
if token == "" {
return nil
return nil, nil
}
token = strings.Replace(token, "Bearer ", "", 1)
user = &User{}
if DB.Where("access_token = ?", token).First(user).RowsAffected == 1 {
return user
user := &User{}
err := DB.Where("access_token = ?", token).First(user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, fmt.Errorf("%w: %v", ErrDatabase, err)
}
return nil
return user, nil
}
// GetUserQuota gets quota from Redis first, falls back to DB if needed
@@ -896,7 +905,7 @@ func increaseUserQuota(id int, quota int) (err error) {
return err
}
func DecreaseUserQuota(id int, quota int) (err error) {
func DecreaseUserQuota(id int, quota int, db bool) (err error) {
if quota < 0 {
return errors.New("quota 不能为负数!")
}
@@ -906,7 +915,7 @@ func DecreaseUserQuota(id int, quota int) (err error) {
common.SysLog("failed to decrease user quota: " + err.Error())
}
})
if common.BatchUpdateEnabled {
if !db && common.BatchUpdateEnabled {
addNewRecord(BatchUpdateTypeUserQuota, id, -quota)
return nil
}
@@ -928,7 +937,7 @@ func DeltaUpdateUserQuota(id int, delta int) (err error) {
if delta > 0 {
return IncreaseUserQuota(id, delta, false)
} else {
return DecreaseUserQuota(id, -delta)
return DecreaseUserQuota(id, -delta, false)
}
}
+11 -6
View File
@@ -171,12 +171,17 @@ type AliImageRequest struct {
}
type AliImageParameters struct {
Size string `json:"size,omitempty"`
N int `json:"n,omitempty"`
Steps string `json:"steps,omitempty"`
Scale string `json:"scale,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
PromptExtend *bool `json:"prompt_extend,omitempty"`
Size string `json:"size,omitempty"`
N int `json:"n,omitempty"`
Steps string `json:"steps,omitempty"`
Scale string `json:"scale,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
PromptExtend *bool `json:"prompt_extend,omitempty"`
ThinkingMode *bool `json:"thinking_mode,omitempty"`
EnableSequential *bool `json:"enable_sequential,omitempty"`
BboxList any `json:"bbox_list,omitempty"`
ColorPalette any `json:"color_palette,omitempty"`
Seed *int `json:"seed,omitempty"`
}
func (p *AliImageParameters) PromptExtendValue() bool {
+1 -2
View File
@@ -54,7 +54,6 @@ func oaiImage2AliImageRequest(info *relaycommon.RelayInfo, request dto.ImageRequ
}
}
// 检查n参数
if imageRequest.Parameters.N != 0 {
info.PriceData.AddOtherRatio("n", float64(imageRequest.Parameters.N))
}
@@ -181,6 +180,7 @@ func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, reque
},
}
imageRequest.Parameters = AliImageParameters{
N: int(lo.FromPtrOr(request.N, uint(1))),
Watermark: request.Watermark,
}
return &imageRequest, nil
@@ -328,7 +328,6 @@ func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *rela
}
imageResponses := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat)
// 可能生成多张图片,修正计费数量n
if aliResponse.Usage.ImageCount != 0 {
info.PriceData.AddOtherRatio("n", float64(aliResponse.Usage.ImageCount))
} else if len(imageResponses.Data) != 0 {
+2 -1
View File
@@ -40,7 +40,8 @@ func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, requ
}
func isOldWanModel(modelName string) bool {
return strings.Contains(modelName, "wan") && !strings.Contains(modelName, "wan2.6")
return strings.Contains(modelName, "wan") &&
!lo.SomeBy([]string{"wan2.6", "wan2.7"}, func(v string) bool { return strings.Contains(modelName, v) })
}
func isWanModel(modelName string) bool {
@@ -85,7 +85,7 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
require.EqualValues(t, 50, usage.CacheCreationInputTokens)
require.EqualValues(t, 53, usage.OutputTokens)
require.NotNil(t, usage.CacheCreation)
require.EqualValues(t, 10, usage.CacheCreation.Ephemeral5mInputTokens)
require.EqualValues(t, 30, usage.CacheCreation.Ephemeral5mInputTokens)
require.EqualValues(t, 20, usage.CacheCreation.Ephemeral1hInputTokens)
})
@@ -108,4 +108,22 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
require.EqualValues(t, 7, usage.CacheReadInputTokens)
require.EqualValues(t, 6, usage.CacheCreationInputTokens)
})
t.Run("default aggregate cache creation to 5m when split missing", func(t *testing.T) {
claudeResponse := &dto.ClaudeResponse{Usage: &dto.ClaudeUsage{
OutputTokens: 53,
CacheCreationInputTokens: 50,
}}
claudeInfo := &ClaudeResponseInfo{Usage: &dto.Usage{
PromptTokensDetails: dto.InputTokenDetails{
CachedCreationTokens: 50,
},
}}
usage := buildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
require.NotNil(t, usage)
require.NotNil(t, usage.CacheCreation)
require.EqualValues(t, 50, usage.CacheCreation.Ephemeral5mInputTokens)
require.EqualValues(t, 0, usage.CacheCreation.Ephemeral1hInputTokens)
})
}
+61 -26
View File
@@ -85,7 +85,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
// 解析 UserLocation JSON
var userLocationMap map[string]interface{}
if err := json.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
if err := common.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
// 检查是否有 approximate 字段
if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok {
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
@@ -177,7 +177,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
}
// TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
claudeRequest.TopP = common.GetPointer[float64](0)
claudeRequest.TopP = nil
claudeRequest.Temperature = common.GetPointer[float64](1.0)
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
@@ -343,33 +343,39 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
} else {
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
for _, mediaMessage := range message.ParseContent() {
claudeMediaMessage := dto.ClaudeMediaMessage{
Type: mediaMessage.Type,
}
if mediaMessage.Type == "text" {
claudeMediaMessage.Text = common.GetPointer[string](mediaMessage.Text)
} else {
imageUrl := mediaMessage.GetImageMedia()
claudeMediaMessage.Type = "image"
claudeMediaMessage.Source = &dto.ClaudeMessageSource{
Type: "base64",
}
// 使用统一的文件服务获取图片数据
var source *types.FileSource
if strings.HasPrefix(imageUrl.Url, "http") {
source = types.NewURLFileSource(imageUrl.Url)
} else {
source = types.NewBase64FileSource(imageUrl.Url, "")
switch mediaMessage.Type {
case "text":
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](mediaMessage.Text),
})
default:
source := mediaMessage.ToFileSource()
if source == nil {
continue
}
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %s", err.Error())
}
claudeMediaMessage := dto.ClaudeMediaMessage{
Source: &dto.ClaudeMessageSource{
Type: "base64",
},
}
if strings.HasPrefix(mimeType, "application/pdf") {
claudeMediaMessage.Type = "document"
} else {
claudeMediaMessage.Type = "image"
}
claudeMediaMessage.Source.MediaType = mimeType
claudeMediaMessage.Source.Data = base64Data
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
continue
}
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
}
if message.ToolCalls != nil {
for _, toolCall := range message.ParseToolCalls() {
inputObj := make(map[string]any)
@@ -574,6 +580,11 @@ func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
return dto.Usage{}
}
clone := *usage
clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = service.NormalizeCacheCreationSplit(
usage.PromptTokensDetails.CachedCreationTokens,
usage.ClaudeCacheCreation5mTokens,
usage.ClaudeCacheCreation1hTokens,
)
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
clone.PromptTokens = totalInputTokens
@@ -603,11 +614,26 @@ func buildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo
if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
}
if usage.CacheCreation == nil && (claudeInfo.Usage.ClaudeCacheCreation5mTokens > 0 || claudeInfo.Usage.ClaudeCacheCreation1hTokens > 0) {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: claudeInfo.Usage.ClaudeCacheCreation5mTokens,
Ephemeral1hInputTokens: claudeInfo.Usage.ClaudeCacheCreation1hTokens,
}
cacheCreation5m := 0
cacheCreation1h := 0
if usage.CacheCreation != nil {
cacheCreation5m = usage.CacheCreation.Ephemeral5mInputTokens
cacheCreation1h = usage.CacheCreation.Ephemeral1hInputTokens
} else {
cacheCreation5m = claudeInfo.Usage.ClaudeCacheCreation5mTokens
cacheCreation1h = claudeInfo.Usage.ClaudeCacheCreation1hTokens
}
cacheCreation5m, cacheCreation1h = service.NormalizeCacheCreationSplit(
usage.CacheCreationInputTokens,
cacheCreation5m,
cacheCreation1h,
)
if usage.CacheCreation == nil && (cacheCreation5m > 0 || cacheCreation1h > 0) {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{}
}
if usage.CacheCreation != nil {
usage.CacheCreation.Ephemeral5mInputTokens = cacheCreation5m
usage.CacheCreation.Ephemeral1hInputTokens = cacheCreation1h
}
return usage
}
@@ -783,7 +809,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
if common.DebugEnabled {
common.SysLog("claude response usage is not complete, maybe upstream error")
}
claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
// 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段
fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
if claudeInfo.Usage.CompletionTokens == 0 ||
(!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) {
claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens
}
if claudeInfo.Usage.PromptTokens == 0 {
claudeInfo.Usage.PromptTokens = fallback.PromptTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
}
if claudeInfo.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic"
+125
View File
@@ -1,10 +1,12 @@
package claude
import (
"encoding/base64"
"strings"
"testing"
"github.com/QuantumNous/new-api/dto"
"github.com/stretchr/testify/require"
)
func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) {
@@ -255,3 +257,126 @@ func TestBuildOpenAIStyleUsageFromClaudeUsagePreservesCacheCreationRemainder(t *
})
}
}
func TestBuildOpenAIStyleUsageFromClaudeUsageDefaultsAggregateCacheCreationTo5m(t *testing.T) {
usage := &dto.Usage{
PromptTokens: 100,
CompletionTokens: 20,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 30,
CachedCreationTokens: 50,
},
UsageSemantic: "anthropic",
}
openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(usage)
require.Equal(t, 50, openAIUsage.ClaudeCacheCreation5mTokens)
require.Equal(t, 0, openAIUsage.ClaudeCacheCreation1hTokens)
}
func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
Messages: []dto.Message{
{
Role: "user",
Content: []any{
dto.MediaContent{
Type: dto.ContentTypeText,
Text: "see attachment",
},
dto.MediaContent{
Type: dto.ContentTypeFile,
File: &dto.MessageFile{
FileName: "blob.bin",
FileData: "JVBERi0xLjQK",
},
},
},
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Len(t, claudeRequest.Messages, 1)
content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
require.True(t, ok)
require.Len(t, content, 1)
require.Equal(t, "text", content[0].Type)
require.NotNil(t, content[0].Text)
require.Equal(t, "see attachment", *content[0].Text)
}
func TestRequestOpenAI2ClaudeMessage_SupportsPDFFileContent(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
Messages: []dto.Message{
{
Role: "user",
Content: []any{
dto.MediaContent{
Type: dto.ContentTypeFile,
File: &dto.MessageFile{
FileName: "spec.pdf",
FileData: "JVBERi0xLjQK",
},
},
dto.MediaContent{
Type: dto.ContentTypeText,
Text: "summarize it",
},
},
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Len(t, claudeRequest.Messages, 1)
content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
require.True(t, ok)
require.Len(t, content, 2)
require.Equal(t, "document", content[0].Type)
require.NotNil(t, content[0].Source)
require.Equal(t, "base64", content[0].Source.Type)
require.Equal(t, "application/pdf", content[0].Source.MediaType)
require.Equal(t, "JVBERi0xLjQK", content[0].Source.Data)
require.Equal(t, "text", content[1].Type)
require.NotNil(t, content[1].Text)
require.Equal(t, "summarize it", *content[1].Text)
}
func TestRequestOpenAI2ClaudeMessage_ConvertsTextFileContentToText(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
Messages: []dto.Message{
{
Role: "user",
Content: []any{
dto.MediaContent{
Type: dto.ContentTypeFile,
File: &dto.MessageFile{
FileName: "notes.txt",
FileData: base64.StdEncoding.EncodeToString([]byte("alpha\nbeta")),
},
},
},
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Len(t, claudeRequest.Messages, 1)
content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
require.True(t, ok)
require.Len(t, content, 1)
require.Equal(t, "text", content[0].Type)
require.NotNil(t, content[0].Text)
require.Equal(t, "alpha\nbeta", *content[0].Text)
}
+6 -38
View File
@@ -37,6 +37,8 @@ var geminiSupportedMimeTypes = map[string]bool{
"image/jpeg": true,
"image/jpg": true, // support old image/jpeg
"image/webp": true,
"image/heic": true,
"image/heif": true,
"text/plain": true,
"video/mov": true,
"video/mpeg": true,
@@ -583,14 +585,10 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
Text: part.Text,
})
}
} else if part.Type == dto.ContentTypeImageURL {
// 使用统一的文件服务获取图片数据
var source *types.FileSource
imageUrl := part.GetImageMedia().Url
if strings.HasPrefix(imageUrl, "http") {
source = types.NewURLFileSource(imageUrl)
} else {
source = types.NewBase64FileSource(imageUrl, "")
} else {
source := part.ToFileSource()
if source == nil {
continue
}
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Gemini")
if err != nil {
@@ -602,36 +600,6 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), getSupportedMimeTypesList())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
} else if part.Type == dto.ContentTypeFile {
if part.GetFile().FileId != "" {
return nil, fmt.Errorf("only base64 file is supported in gemini")
}
fileSource := types.NewBase64FileSource(part.GetFile().FileData, "")
base64Data, mimeType, err := service.GetBase64Data(c, fileSource, "formatting file for Gemini")
if err != nil {
return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
} else if part.Type == dto.ContentTypeInputAudio {
if part.GetInputAudio().Data == "" {
return nil, fmt.Errorf("only base64 audio is supported in gemini")
}
audioSource := types.NewBase64FileSource(part.GetInputAudio().Data, "audio/"+part.GetInputAudio().Format)
base64Data, mimeType, err := service.GetBase64Data(c, audioSource, "formatting audio for Gemini")
if err != nil {
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
+7 -1
View File
@@ -78,7 +78,10 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
return request, nil
if info.RelayMode != constant.RelayModeImagesGenerations {
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
}
return oaiImage2MiniMaxImageRequest(request), nil
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
@@ -121,6 +124,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
if info.RelayMode == constant.RelayModeAudioSpeech {
return handleTTSResponse(c, resp, info)
}
if info.RelayMode == constant.RelayModeImagesGenerations {
return miniMaxImageHandler(c, resp, info)
}
switch info.RelayFormat {
case types.RelayFormatClaude:
+137
View File
@@ -0,0 +1,137 @@
package minimax
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/gin-gonic/gin"
)
func TestGetRequestURLForImageGeneration(t *testing.T) {
t.Parallel()
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
ChannelMeta: &relaycommon.ChannelMeta{
ChannelBaseUrl: "https://api.minimax.chat",
},
}
got, err := GetRequestURL(info)
if err != nil {
t.Fatalf("GetRequestURL returned error: %v", err)
}
want := "https://api.minimax.chat/v1/image_generation"
if got != want {
t.Fatalf("GetRequestURL() = %q, want %q", got, want)
}
}
func TestConvertImageRequest(t *testing.T) {
t.Parallel()
adaptor := &Adaptor{}
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
OriginModelName: "image-01",
}
request := dto.ImageRequest{
Model: "image-01",
Prompt: "a red fox in snowfall",
Size: "1536x1024",
ResponseFormat: "url",
N: uintPtr(2),
}
got, err := adaptor.ConvertImageRequest(gin.CreateTestContextOnly(httptest.NewRecorder(), gin.New()), info, request)
if err != nil {
t.Fatalf("ConvertImageRequest returned error: %v", err)
}
body, err := json.Marshal(got)
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
if payload["model"] != "image-01" {
t.Fatalf("model = %#v, want %q", payload["model"], "image-01")
}
if payload["prompt"] != request.Prompt {
t.Fatalf("prompt = %#v, want %q", payload["prompt"], request.Prompt)
}
if payload["n"] != float64(2) {
t.Fatalf("n = %#v, want 2", payload["n"])
}
if payload["aspect_ratio"] != "3:2" {
t.Fatalf("aspect_ratio = %#v, want %q", payload["aspect_ratio"], "3:2")
}
if payload["response_format"] != "url" {
t.Fatalf("response_format = %#v, want %q", payload["response_format"], "url")
}
}
func TestDoResponseForImageGeneration(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
StartTime: time.Unix(1700000000, 0),
}
resp := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: httptest.NewRecorder().Result().Body,
}
resp.Body = ioNopCloser(`{"data":{"image_urls":["https://example.com/minimax.png"]}}`)
adaptor := &Adaptor{}
usage, err := adaptor.DoResponse(c, resp, info)
if err != nil {
t.Fatalf("DoResponse returned error: %v", err)
}
if usage == nil {
t.Fatalf("DoResponse returned nil usage")
}
body := recorder.Body.String()
if !strings.Contains(body, `"url":"https://example.com/minimax.png"`) {
t.Fatalf("response body = %s, want OpenAI image response with image URL", body)
}
if strings.Contains(body, `"image_urls"`) {
t.Fatalf("response body = %s, should not expose raw MiniMax image_urls payload", body)
}
}
type nopReadCloser struct {
*strings.Reader
}
func (n nopReadCloser) Close() error {
return nil
}
func ioNopCloser(body string) nopReadCloser {
return nopReadCloser{Reader: strings.NewReader(body)}
}
func uintPtr(v uint) *uint {
return &v
}
+4
View File
@@ -8,6 +8,8 @@ var ModelList = []string{
"abab6-chat",
"abab5.5-chat",
"abab5.5s-chat",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"speech-2.5-hd-preview",
"speech-2.5-turbo-preview",
"speech-02-hd",
@@ -19,6 +21,8 @@ var ModelList = []string{
"MiniMax-M2",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
"image-01",
"image-01-live",
}
var ChannelName = "minimax"
+213
View File
@@ -0,0 +1,213 @@
package minimax
import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
type MiniMaxImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
AspectRatio string `json:"aspect_ratio,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
N int `json:"n,omitempty"`
PromptOptimizer *bool `json:"prompt_optimizer,omitempty"`
AigcWatermark *bool `json:"aigc_watermark,omitempty"`
}
type MiniMaxImageResponse struct {
ID string `json:"id"`
Data struct {
ImageURLs []string `json:"image_urls"`
ImageBase64 []string `json:"image_base64"`
} `json:"data"`
Metadata map[string]any `json:"metadata"`
BaseResp struct {
StatusCode int `json:"status_code"`
StatusMsg string `json:"status_msg"`
} `json:"base_resp"`
}
func oaiImage2MiniMaxImageRequest(request dto.ImageRequest) MiniMaxImageRequest {
responseFormat := normalizeMiniMaxResponseFormat(request.ResponseFormat)
minimaxRequest := MiniMaxImageRequest{
Model: request.Model,
Prompt: request.Prompt,
ResponseFormat: responseFormat,
N: 1,
AigcWatermark: request.Watermark,
}
if request.Model == "" {
minimaxRequest.Model = "image-01"
}
if request.N != nil && *request.N > 0 {
minimaxRequest.N = int(*request.N)
}
if aspectRatio := aspectRatioFromImageRequest(request); aspectRatio != "" {
minimaxRequest.AspectRatio = aspectRatio
}
if raw, ok := request.Extra["prompt_optimizer"]; ok {
var promptOptimizer bool
if err := common.Unmarshal(raw, &promptOptimizer); err == nil {
minimaxRequest.PromptOptimizer = &promptOptimizer
}
}
return minimaxRequest
}
func aspectRatioFromImageRequest(request dto.ImageRequest) string {
if raw, ok := request.Extra["aspect_ratio"]; ok {
var aspectRatio string
if err := common.Unmarshal(raw, &aspectRatio); err == nil && aspectRatio != "" {
return aspectRatio
}
}
switch request.Size {
case "1024x1024":
return "1:1"
case "1792x1024":
return "16:9"
case "1024x1792":
return "9:16"
case "1536x1024", "1248x832":
return "3:2"
case "1024x1536", "832x1248":
return "2:3"
case "1152x864":
return "4:3"
case "864x1152":
return "3:4"
case "1344x576":
return "21:9"
}
width, height, ok := parseImageSize(request.Size)
if !ok {
return ""
}
ratio := reduceAspectRatio(width, height)
switch ratio {
case "1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9":
return ratio
default:
return ""
}
}
func parseImageSize(size string) (int, int, bool) {
parts := strings.Split(size, "x")
if len(parts) != 2 {
return 0, 0, false
}
width, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, false
}
height, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, false
}
if width <= 0 || height <= 0 {
return 0, 0, false
}
return width, height, true
}
func reduceAspectRatio(width, height int) string {
divisor := gcd(width, height)
return fmt.Sprintf("%d:%d", width/divisor, height/divisor)
}
func gcd(a, b int) int {
for b != 0 {
a, b = b, a%b
}
if a == 0 {
return 1
}
return a
}
func normalizeMiniMaxResponseFormat(responseFormat string) string {
switch strings.ToLower(responseFormat) {
case "", "url":
return "url"
case "b64_json", "base64":
return "base64"
default:
return responseFormat
}
}
func responseMiniMax2OpenAIImage(response *MiniMaxImageResponse, info *relaycommon.RelayInfo) (*dto.ImageResponse, error) {
imageResponse := &dto.ImageResponse{
Created: info.StartTime.Unix(),
}
for _, imageURL := range response.Data.ImageURLs {
imageResponse.Data = append(imageResponse.Data, dto.ImageData{Url: imageURL})
}
for _, imageBase64 := range response.Data.ImageBase64 {
imageResponse.Data = append(imageResponse.Data, dto.ImageData{B64Json: imageBase64})
}
if len(response.Metadata) > 0 {
metadata, err := common.Marshal(response.Metadata)
if err != nil {
return nil, err
}
imageResponse.Metadata = metadata
}
return imageResponse, nil
}
func miniMaxImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
service.CloseResponseBodyGracefully(resp)
var minimaxResponse MiniMaxImageResponse
if err := common.Unmarshal(responseBody, &minimaxResponse); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if minimaxResponse.BaseResp.StatusCode != 0 {
return nil, types.WithOpenAIError(types.OpenAIError{
Message: minimaxResponse.BaseResp.StatusMsg,
Type: "minimax_image_error",
Code: fmt.Sprintf("%d", minimaxResponse.BaseResp.StatusCode),
}, resp.StatusCode)
}
openAIResponse, err := responseMiniMax2OpenAIImage(&minimaxResponse, info)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
jsonResponse, err := common.Marshal(openAIResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
if _, err := c.Writer.Write(jsonResponse); err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
return &dto.Usage{}, nil
}
+2
View File
@@ -21,6 +21,8 @@ func GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
switch info.RelayMode {
case constant.RelayModeChatCompletions:
return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/v1/image_generation", baseUrl), nil
case constant.RelayModeAudioSpeech:
return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil
default:
+2 -9
View File
@@ -98,15 +98,8 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
parts := m.ParseContent()
for _, part := range parts {
if part.Type == dto.ContentTypeImageURL {
img := part.GetImageMedia()
if img != nil && img.Url != "" {
// 使用统一的文件服务获取图片数据
var source *types.FileSource
if strings.HasPrefix(img.Url, "http") {
source = types.NewURLFileSource(img.Url)
} else {
source = types.NewBase64FileSource(img.Url, "")
}
source := part.ToFileSource()
if source != nil {
base64Data, _, err := service.GetBase64Data(c, source, "fetch image for ollama chat")
if err != nil {
return nil, err
+1 -1
View File
@@ -369,7 +369,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
a.ResponseFormat = request.ResponseFormat
if info.RelayMode == relayconstant.RelayModeAudioSpeech {
jsonData, err := json.Marshal(request)
jsonData, err := common.Marshal(request)
if err != nil {
return nil, fmt.Errorf("error marshalling object: %w", err)
}
+3 -3
View File
@@ -80,9 +80,9 @@ type AliVideoOutput struct {
// AliUsage 使用统计
type AliUsage struct {
Duration int `json:"duration,omitempty"`
VideoCount int `json:"video_count,omitempty"`
SR int `json:"SR,omitempty"`
Duration dto.IntValue `json:"duration,omitempty"`
VideoCount dto.IntValue `json:"video_count,omitempty"`
SR dto.IntValue `json:"SR,omitempty"`
}
type AliMetadata struct {
+93 -36
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
@@ -13,12 +14,13 @@ import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel"
taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
"github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"github.com/samber/lo"
)
// ============================
@@ -26,37 +28,37 @@ import (
// ============================
type ContentItem struct {
Type string `json:"type"` // "text", "image_url" or "video"
Text string `json:"text,omitempty"` // for text type
ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type
Video *VideoReference `json:"video,omitempty"` // for video (sample) type
Role string `json:"role,omitempty"` // reference_image / first_frame / last_frame
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
ImageURL *MediaURL `json:"image_url,omitempty"`
VideoURL *MediaURL `json:"video_url,omitempty"`
AudioURL *MediaURL `json:"audio_url,omitempty"`
Role string `json:"role,omitempty"`
}
type ImageURL struct {
URL string `json:"url"`
}
type VideoReference struct {
URL string `json:"url"` // Draft video URL
type MediaURL struct {
URL string `json:"url,omitempty"`
}
type requestPayload struct {
Model string `json:"model"`
Content []ContentItem `json:"content"`
Content []ContentItem `json:"content,omitempty"`
CallbackURL string `json:"callback_url,omitempty"`
ReturnLastFrame *dto.BoolValue `json:"return_last_frame,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
ExecutionExpiresAfter dto.IntValue `json:"execution_expires_after,omitempty"`
ExecutionExpiresAfter *dto.IntValue `json:"execution_expires_after,omitempty"`
GenerateAudio *dto.BoolValue `json:"generate_audio,omitempty"`
Draft *dto.BoolValue `json:"draft,omitempty"`
Resolution string `json:"resolution,omitempty"`
Ratio string `json:"ratio,omitempty"`
Duration dto.IntValue `json:"duration,omitempty"`
Frames dto.IntValue `json:"frames,omitempty"`
Seed dto.IntValue `json:"seed,omitempty"`
CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"`
Watermark *dto.BoolValue `json:"watermark,omitempty"`
Tools []struct {
Type string `json:"type,omitempty"`
} `json:"tools,omitempty"`
Resolution string `json:"resolution,omitempty"`
Ratio string `json:"ratio,omitempty"`
Duration *dto.IntValue `json:"duration,omitempty"`
Frames *dto.IntValue `json:"frames,omitempty"`
Seed *dto.IntValue `json:"seed,omitempty"`
CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"`
Watermark *dto.BoolValue `json:"watermark,omitempty"`
}
type responsePayload struct {
@@ -76,10 +78,20 @@ type responseTask struct {
Ratio string `json:"ratio"`
FramesPerSecond int `json:"framespersecond"`
ServiceTier string `json:"service_tier"`
Usage struct {
Tools []struct {
Type string `json:"type"`
} `json:"tools"`
Usage struct {
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
ToolUsage struct {
WebSearch int `json:"web_search"`
} `json:"tool_usage"`
} `json:"usage"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
@@ -108,18 +120,61 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
}
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
func (a *TaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil
}
// BuildRequestHeader sets required headers.
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+a.apiKey)
return nil
}
// EstimateBilling 检测请求 metadata 中是否包含视频输入,返回视频折扣 OtherRatio。
func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
req, err := relaycommon.GetTaskRequest(c)
if err != nil {
return nil
}
if hasVideoInMetadata(req.Metadata) {
if ratio, ok := GetVideoInputRatio(info.OriginModelName); ok {
return map[string]float64{"video_input": ratio}
}
}
return nil
}
// hasVideoInMetadata 直接检查 metadata 的 content 数组是否包含 video_url 条目,
// 避免构建完整的上游 requestPayload。
func hasVideoInMetadata(metadata map[string]interface{}) bool {
if metadata == nil {
return false
}
contentRaw, ok := metadata["content"]
if !ok {
return false
}
contentSlice, ok := contentRaw.([]interface{})
if !ok {
return false
}
for _, item := range contentSlice {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
if itemMap["type"] == "video_url" {
return true
}
if _, has := itemMap["video_url"]; has {
return true
}
}
return false
}
// BuildRequestBody converts request into Doubao specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
req, err := relaycommon.GetTaskRequest(c)
@@ -218,20 +273,12 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
Content: []ContentItem{},
}
// Add text prompt
if req.Prompt != "" {
r.Content = append(r.Content, ContentItem{
Type: "text",
Text: req.Prompt,
})
}
// Add images if present
if req.HasImage() {
for _, imgURL := range req.Images {
r.Content = append(r.Content, ContentItem{
Type: "image_url",
ImageURL: &ImageURL{
ImageURL: &MediaURL{
URL: imgURL,
},
})
@@ -243,6 +290,16 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
return nil, errors.Wrap(err, "unmarshal metadata failed")
}
if sec, _ := strconv.Atoi(req.Seconds); sec > 0 {
r.Duration = lo.ToPtr(dto.IntValue(sec))
}
r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" })
r.Content = append(r.Content, ContentItem{
Type: "text",
Text: req.Prompt,
})
return &r, nil
}
@@ -274,7 +331,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
case "failed":
taskResult.Status = model.TaskStatusFailure
taskResult.Progress = "100%"
taskResult.Reason = "task failed"
taskResult.Reason = resTask.Error.Message
default:
// Unknown status, treat as processing
taskResult.Status = model.TaskStatusInProgress
@@ -302,8 +359,8 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, erro
if dResp.Status == "failed" {
openAIVideo.Error = &dto.OpenAIVideoError{
Message: "task failed",
Code: "failed",
Message: dResp.Error.Message,
Code: dResp.Error.Code,
}
}
+15
View File
@@ -5,6 +5,21 @@ var ModelList = []string{
"doubao-seedance-1-0-lite-t2v",
"doubao-seedance-1-0-lite-i2v",
"doubao-seedance-1-5-pro-251215",
"doubao-seedance-2-0-260128",
"doubao-seedance-2-0-fast-260128",
}
var ChannelName = "doubao-video"
// videoInputRatioMap 视频输入折扣比率(含视频单价 / 不含视频单价)。
// 管理员应将 ModelRatio 设置为"不含视频"的较高费率,
// 系统在检测到视频输入时自动乘以此折扣。
var videoInputRatioMap = map[string]float64{
"doubao-seedance-2-0-260128": 28.0 / 46.0, // ~0.6087
"doubao-seedance-2-0-fast-260128": 22.0 / 37.0, // ~0.5946
}
func GetVideoInputRatio(modelName string) (float64, bool) {
r, ok := videoInputRatioMap[modelName]
return r, ok
}
+1 -1
View File
@@ -76,7 +76,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if strings.HasPrefix(request.Model, "grok-3-mini") {
if lo.FromPtrOr(request.MaxCompletionTokens, uint(0)) == 0 && lo.FromPtrOr(request.MaxTokens, uint(0)) != 0 {
request.MaxCompletionTokens = request.MaxTokens
request.MaxTokens = lo.ToPtr(uint(0))
request.MaxTokens = nil
}
if strings.HasSuffix(request.Model, "-high") {
request.ReasoningEffort = "high"
+3
View File
@@ -64,6 +64,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
}
return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil
case relayconstant.RelayModeImagesGenerations:
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil
}
return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil
default:
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
+16
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
@@ -690,6 +691,7 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
type Alias TaskSubmitReq
aux := &struct {
Metadata json.RawMessage `json:"metadata,omitempty"`
Duration json.RawMessage `json:"duration,omitempty"`
*Alias
}{
Alias: (*Alias)(t),
@@ -699,6 +701,20 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
return err
}
if len(aux.Duration) > 0 {
var durationInt int
if err := common.Unmarshal(aux.Duration, &durationInt); err == nil {
t.Duration = durationInt
} else {
var durationStr string
if err := common.Unmarshal(aux.Duration, &durationStr); err == nil && durationStr != "" {
if v, err := strconv.Atoi(durationStr); err == nil {
t.Duration = v
}
}
}
}
if len(aux.Metadata) > 0 {
var metadataStr string
if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" {
+3 -1
View File
@@ -204,7 +204,9 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
if err != nil {
return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true)
}
} else if err := common.UnmarshalBodyReusable(c, &req); err != nil {
}
// 为了metadata字段的兼容性,统一UnmarshalBodyReusable
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
}
+47 -17
View File
@@ -5,6 +5,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -13,6 +14,21 @@ import (
"github.com/gin-gonic/gin"
)
func modelPriceNotConfiguredError(modelName string, userId int) error {
if model.IsAdmin(userId) {
return fmt.Errorf(
"模型 %s 的价格未配置。请前往「系统设置 → 运营设置」开启自用模式,或在「系统设置 → 分组与模型定价设置」中为该模型配置价格;"+
"Model %s price not configured. Go to System Settings → Operation Settings to enable self-use mode, or configure the model price in System Settings → Group & Model Pricing.",
modelName, modelName,
)
}
return fmt.Errorf(
"模型 %s 的价格尚未由管理员配置,暂时无法使用,请联系站点管理员开启该模型;"+
"Model %s has not been priced by the administrator yet. Please contact the site administrator to enable this model.",
modelName, modelName,
)
}
// https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration
const claudeCacheCreation1hMultiplier = 6 / 3.75
@@ -75,7 +91,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
acceptUnsetRatio = true
}
if !acceptUnsetRatio {
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
return types.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
}
}
completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName)
@@ -139,47 +155,61 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
return priceData, nil
}
// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task)
// ModelPriceHelperPerCall 按次/按量计费的 PriceHelper (MJ、Task)
func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) {
groupRatioInfo := HandleGroupRatio(c, info)
modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
// 如果没有配置价格,检查模型倍率配置
if !success {
usePrice := success
var modelRatio float64
// 没有配置费用,也要使用默认费用,否则按费率计费模型无法使用
if !success {
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
if ok {
modelPrice = defaultPrice
usePrice = true
} else {
// 没有配置倍率也不接受没配置,那就返回错误
_, ratioSuccess, matchName := ratio_setting.GetModelRatio(info.OriginModelName)
var ratioSuccess bool
var matchName string
modelRatio, ratioSuccess, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
acceptUnsetRatio := false
if info.UserSetting.AcceptUnsetRatioModel {
acceptUnsetRatio = true
}
if !ratioSuccess && !acceptUnsetRatio {
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
return types.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
}
// 未配置价格但配置了倍率,使用默认预扣价格
modelPrice = float64(common.PreConsumedQuota) / common.QuotaPerUnit
}
}
quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
// 免费模型检测(与 ModelPriceHelper 对齐)
var quota int
freeModel := false
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
quota = 0
freeModel = true
if usePrice {
quota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
quota = 0
freeModel = true
}
}
} else {
// 按量计费:以模型倍率的一半作为预扣额度
quota = int(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
modelPrice = -1
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 {
quota = 0
freeModel = true
}
}
}
priceData := types.PriceData{
FreeModel: freeModel,
ModelPrice: modelPrice,
ModelRatio: modelRatio,
UsePrice: usePrice,
Quota: quota,
GroupRatioInfo: groupRatioInfo,
}
+11 -2
View File
@@ -117,11 +117,20 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
if request.N != nil {
imageN = *request.N
}
// n is handled via OtherRatio so it is applied exactly once in quota
// calculation (both price-based and ratio-based paths).
// Adaptors may have already set a more accurate count from the
// upstream response; only set the default when they haven't.
if _, hasN := info.PriceData.OtherRatios["n"]; !hasN {
info.PriceData.AddOtherRatio("n", float64(imageN))
}
if usage.(*dto.Usage).TotalTokens == 0 {
usage.(*dto.Usage).TotalTokens = int(imageN)
usage.(*dto.Usage).TotalTokens = 1
}
if usage.(*dto.Usage).PromptTokens == 0 {
usage.(*dto.Usage).PromptTokens = int(imageN)
usage.(*dto.Usage).PromptTokens = 1
}
quality := "standard"
+7
View File
@@ -49,6 +49,13 @@ func RelayMidjourneyImage(c *gin.Context) {
if httpClient == nil {
httpClient = service.GetHttpClient()
}
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(midjourneyTask.ImageUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
c.JSON(http.StatusForbidden, gin.H{
"error": fmt.Sprintf("request blocked: %v", err),
})
return
}
resp, err := httpClient.Get(midjourneyTask.ImageUrl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
+1 -1
View File
@@ -143,7 +143,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
info.OriginModelName = originModelName
info.PriceData = originPriceData
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry(), types.ErrOptionWithStatusCode(http.StatusBadRequest))
}
service.PostTextConsumeQuota(c, info, usageDto, nil)
+5 -3
View File
@@ -36,10 +36,10 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
// OAuth routes - specific routes must come before :provider wildcard
apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode)
apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind)
apiRouter.POST("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind)
// Non-standard OAuth (WeChat, Telegram) - keep original routes
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind)
apiRouter.POST("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind)
apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin)
apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind)
// Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route
@@ -226,7 +226,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.POST("/batch", controller.DeleteChannelBatch)
channelRoute.POST("/fix", controller.FixChannelsAbilities)
channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
channelRoute.POST("/fetch_models", controller.FetchModels)
channelRoute.POST("/fetch_models", middleware.RootAuth(), controller.FetchModels)
channelRoute.POST("/codex/oauth/start", controller.StartCodexOAuth)
channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth)
channelRoute.POST("/:id/codex/oauth/start", controller.StartCodexOAuthForChannel)
@@ -257,6 +257,7 @@ func SetApiRouter(router *gin.Engine) {
tokenRoute.PUT("/", controller.UpdateToken)
tokenRoute.DELETE("/:id", controller.DeleteToken)
tokenRoute.POST("/batch", controller.DeleteTokenBatch)
tokenRoute.POST("/batch/keys", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKeysBatch)
}
usageRoute := apiRouter.Group("/usage")
@@ -292,6 +293,7 @@ func SetApiRouter(router *gin.Engine) {
dataRoute := apiRouter.Group("/data")
dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates)
dataRoute.GET("/users", middleware.AdminAuth(), controller.GetQuotaDatesByUser)
dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates)
logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
+17 -4
View File
@@ -166,12 +166,22 @@ func GetChannelAffinityCacheStats() ChannelAffinityCacheStats {
unknown++
continue
}
if rule.IncludeUsingGroup {
if rule.IncludeModelName {
if len(parts) < 3 {
unknown++
continue
}
}
if rule.IncludeUsingGroup {
minParts := 3
if rule.IncludeModelName {
minParts = 4
}
if len(parts) < minParts {
unknown++
continue
}
}
byRuleName[ruleName]++
}
@@ -319,11 +329,14 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
}
}
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 3)
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 4)
if rule.IncludeRuleName && rule.Name != "" {
parts = append(parts, rule.Name)
}
if rule.IncludeModelName && modelName != "" {
parts = append(parts, modelName)
}
if rule.IncludeUsingGroup && usingGroup != "" {
parts = append(parts, usingGroup)
}
@@ -573,7 +586,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
if ttlSeconds <= 0 {
ttlSeconds = setting.DefaultTTLSeconds
}
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, usingGroup, affinityValue)
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue)
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
setChannelAffinityContext(c, channelAffinityMeta{
CacheKey: cacheKeyFull,
+1 -1
View File
@@ -193,7 +193,7 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
require.NotNil(t, codexRule)
affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "default", affinityValue)
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "gpt-5", "default", affinityValue)
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
+37 -15
View File
@@ -227,21 +227,31 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
if oaiUsage == nil {
return nil
}
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
oaiUsage.PromptTokensDetails.CachedCreationTokens,
oaiUsage.ClaudeCacheCreation5mTokens,
oaiUsage.ClaudeCacheCreation1hTokens,
)
usage := &dto.ClaudeUsage{
InputTokens: oaiUsage.PromptTokens,
OutputTokens: oaiUsage.CompletionTokens,
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
}
if oaiUsage.ClaudeCacheCreation5mTokens > 0 || oaiUsage.ClaudeCacheCreation1hTokens > 0 {
if cacheCreation5m > 0 || cacheCreation1h > 0 {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: oaiUsage.ClaudeCacheCreation5mTokens,
Ephemeral1hInputTokens: oaiUsage.ClaudeCacheCreation1hTokens,
Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: cacheCreation1h,
}
}
return usage
}
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
remainder := lo.Max([]int{totalTokens - tokens5m - tokens1h, 0})
return tokens5m + remainder, tokens1h
}
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
if info.ClaudeConvertInfo.Done {
return nil
@@ -426,23 +436,28 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}
if len(openAIResponse.Choices) == 0 {
// no choices
// 可能为非标准的 OpenAI 响应,判断是否已经完成
if info.ClaudeConvertInfo.Done {
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = info.ClaudeConvertInfo.Usage
}
if oaiUsage != nil {
stopOpenBlocks()
oaiUsage := info.ClaudeConvertInfo.Usage
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
},
})
stopReason := stopReasonOpenAI2Claude(info.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReason),
},
})
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
info.ClaudeConvertInfo.Done = true
}
return claudeResponses
} else {
@@ -450,6 +465,13 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
if doneChunk {
info.FinishReason = *chosenChoice.FinishReason
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = info.ClaudeConvertInfo.Usage
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
// Defer closing until usage is available so the final message_delta carries it.
return claudeResponses
}
}
var claudeResponse dto.ClaudeResponse
+9
View File
@@ -104,6 +104,11 @@ func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, e
return sniffed, nil
}
// Try HEIF/HEIC detection (Go standard library doesn't recognize it)
if heifMime := detectHEIF(readData); heifMime != "" {
return heifMime, nil
}
if _, format, err := image.DecodeConfig(bytes.NewReader(readData)); err == nil {
switch strings.ToLower(format) {
case "jpeg", "jpg":
@@ -168,6 +173,10 @@ func GetMimeTypeByExtension(ext string) string {
return "image/gif"
case "jfif":
return "image/jpeg"
case "heic":
return "image/heic"
case "heif":
return "image/heif"
// Audio files
case "mp3":
+168 -32
View File
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"encoding/base64"
"encoding/binary"
"fmt"
"image"
_ "image/gif"
@@ -24,14 +25,26 @@ import (
// FileService 统一的文件处理服务
// 提供文件下载、解码、缓存等功能的统一入口
// getContextCacheKey 生成 context 缓存的 key
// getContextCacheKey 生成 URL context 缓存的 key
func getContextCacheKey(url string) string {
return fmt.Sprintf("file_cache_%s", common.GenerateHMAC(url))
}
// getBase64ContextCacheKey 生成 base64 context 缓存的 key
// 使用 length + MIME + 前 128 字符作为输入,避免对整个 base64 数据做 hash
func getBase64ContextCacheKey(data string, mimeType string) string {
keyMaterial := fmt.Sprintf("%d:%s:", len(data), mimeType)
if len(data) > 128 {
keyMaterial += data[:128]
} else {
keyMaterial += data
}
return fmt.Sprintf("b64_cache_%s", common.GenerateHMAC(keyMaterial))
}
// LoadFileSource 加载文件源数据
// 这是统一的入口,会自动处理缓存和不同的来源类型
func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string) (*types.CachedFileData, error) {
func LoadFileSource(c *gin.Context, source types.FileSource, reason ...string) (*types.CachedFileData, error) {
if source == nil {
return nil, fmt.Errorf("file source is nil")
}
@@ -42,7 +55,6 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
// 1. 快速检查内部缓存
if source.HasCache() {
// 即使命中内部缓存,也要确保注册到清理列表(如果尚未注册)
if c != nil {
registerSourceForCleanup(c, source)
}
@@ -61,39 +73,49 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
return source.GetCache(), nil
}
// 4. 如果是 URL,检查 Context 缓存
var contextKey string
if source.IsURL() && c != nil {
contextKey = getContextCacheKey(source.URL)
if cachedData, exists := c.Get(contextKey); exists {
data := cachedData.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
// 5. 执行加载逻辑
// 4. 根据来源类型加载(含 URL context 缓存查找)
var cachedData *types.CachedFileData
var contextKey string
var err error
if source.IsURL() {
cachedData, err = loadFromURL(c, source.URL, reason...)
} else {
cachedData, err = loadFromBase64(source.Base64Data, source.MimeType)
switch s := source.(type) {
case *types.URLSource:
if c != nil {
contextKey = getContextCacheKey(s.URL)
if cached, exists := c.Get(contextKey); exists {
data := cached.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
cachedData, err = loadFromURL(c, s.URL, reason...)
case *types.Base64Source:
if c != nil {
contextKey = getBase64ContextCacheKey(s.Base64Data, s.MimeType)
if cached, exists := c.Get(contextKey); exists {
data := cached.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
cachedData, err = loadFromBase64(s.Base64Data, s.MimeType)
default:
return nil, fmt.Errorf("unsupported file source type: %T", source)
}
if err != nil {
return nil, err
}
// 6. 设置缓存
// 5. 设置缓存
source.SetCache(cachedData)
if contextKey != "" && c != nil {
c.Set(contextKey, cachedData)
}
// 7. 注册到 context 以便请求结束时自动清理
// 6. 注册到 context 以便请求结束时自动清理
if c != nil {
registerSourceForCleanup(c, source)
}
@@ -102,15 +124,15 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
}
// registerSourceForCleanup 注册 FileSource 到 context 以便请求结束时清理
func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
func registerSourceForCleanup(c *gin.Context, source types.FileSource) {
if source.IsRegistered() {
return
}
key := string(constant.ContextKeyFileSourcesToCleanup)
var sources []*types.FileSource
var sources []types.FileSource
if existing, exists := c.Get(key); exists {
sources = existing.([]*types.FileSource)
sources = existing.([]types.FileSource)
}
sources = append(sources, source)
c.Set(key, sources)
@@ -122,12 +144,12 @@ func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
func CleanupFileSources(c *gin.Context) {
key := string(constant.ContextKeyFileSourcesToCleanup)
if sources, exists := c.Get(key); exists {
for _, source := range sources.([]*types.FileSource) {
for _, source := range sources.([]types.FileSource) {
if cache := source.GetCache(); cache != nil {
cache.Close()
}
}
c.Set(key, nil) // 清除引用
c.Set(key, nil)
}
}
@@ -275,6 +297,11 @@ func smartDetectMimeType(resp *http.Response, url string, fileBytes []byte) stri
}
return sniffed
}
// 4.5 尝试 HEIF/HEIC 检测(Go 标准库不识别)
if heifMime := detectHEIF(fileBytes); heifMime != "" {
return heifMime
}
}
// 5. 尝试作为图片解码获取格式
@@ -357,7 +384,7 @@ func loadFromBase64(base64String string, providedMimeType string) (*types.Cached
}
// GetImageConfig 获取图片配置
func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, string, error) {
func GetImageConfig(c *gin.Context, source types.FileSource) (image.Config, string, error) {
cachedData, err := LoadFileSource(c, source, "get_image_config")
if err != nil {
return image.Config{}, "", err
@@ -388,7 +415,7 @@ func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, str
}
// GetBase64Data 获取 base64 编码的数据
func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (string, string, error) {
func GetBase64Data(c *gin.Context, source types.FileSource, reason ...string) (string, string, error) {
cachedData, err := LoadFileSource(c, source, reason...)
if err != nil {
return "", "", err
@@ -401,13 +428,13 @@ func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (
}
// GetMimeType 获取文件的 MIME 类型
func GetMimeType(c *gin.Context, source *types.FileSource) (string, error) {
func GetMimeType(c *gin.Context, source types.FileSource) (string, error) {
if source.HasCache() {
return source.GetCache().MimeType, nil
}
if source.IsURL() {
mimeType, err := GetFileTypeFromUrl(c, source.URL, "get_mime_type")
if urlSource, ok := source.(*types.URLSource); ok {
mimeType, err := GetFileTypeFromUrl(c, urlSource.URL, "get_mime_type")
if err == nil && mimeType != "" && mimeType != "application/octet-stream" {
return mimeType, nil
}
@@ -449,9 +476,118 @@ func decodeImageConfig(data []byte) (image.Config, string, error) {
return config, "webp", nil
}
// Try HEIF/HEIC: parse ISOBMFF ispe box for dimensions
if heifMime := detectHEIF(data); heifMime != "" {
formatName := "heif"
if heifMime == "image/heic" {
formatName = "heic"
}
if w, h, ok := parseHEIFDimensions(data); ok {
return image.Config{Width: w, Height: h}, formatName, nil
}
return image.Config{}, "", fmt.Errorf("failed to decode HEIF/HEIC image dimensions")
}
return image.Config{}, "", fmt.Errorf("failed to decode image config: unsupported format")
}
// detectHEIF checks ISOBMFF magic bytes to detect HEIC/HEIF files.
// Returns "image/heic", "image/heif", or "" if not recognized.
func detectHEIF(data []byte) string {
if len(data) < 12 {
return ""
}
// ISOBMFF: bytes[4:8] must be "ftyp"
if string(data[4:8]) != "ftyp" {
return ""
}
brand := string(data[8:12])
switch brand {
case "heic", "heix", "hevc", "hevx", "heim", "heis":
return "image/heic"
case "mif1", "msf1":
return "image/heif"
default:
return ""
}
}
// parseHEIFDimensions parses ISOBMFF box tree to find the ispe box
// and extract image width/height. Returns (width, height, ok).
func parseHEIFDimensions(data []byte) (int, int, bool) {
size := len(data)
if size < 12 {
return 0, 0, false
}
// Walk top-level boxes to find "meta"
offset := 0
for offset+8 <= size {
boxSize := int(binary.BigEndian.Uint32(data[offset : offset+4]))
boxType := string(data[offset+4 : offset+8])
headerLen := 8
if boxSize == 1 {
// 64-bit extended size
if offset+16 > size {
break
}
boxSize = int(binary.BigEndian.Uint64(data[offset+8 : offset+16]))
headerLen = 16
} else if boxSize == 0 {
// box extends to end of data
boxSize = size - offset
}
if boxSize < headerLen || offset+boxSize > size {
break
}
if boxType == "meta" {
// meta is a full box: 4 bytes version/flags after header
metaData := data[offset+headerLen : offset+boxSize]
if len(metaData) < 4 {
return 0, 0, false
}
return findISPE(metaData[4:])
}
offset += boxSize
}
return 0, 0, false
}
// findISPE recursively searches for the ispe box within container boxes.
// Path: meta -> iprp -> ipco -> ispe
func findISPE(data []byte) (int, int, bool) {
offset := 0
size := len(data)
for offset+8 <= size {
boxSize := int(binary.BigEndian.Uint32(data[offset : offset+4]))
boxType := string(data[offset+4 : offset+8])
if boxSize < 8 || offset+boxSize > size {
break
}
content := data[offset+8 : offset+boxSize]
switch boxType {
case "iprp", "ipco":
if w, h, ok := findISPE(content); ok {
return w, h, true
}
case "ispe":
// ispe is a full box: 4 bytes version/flags, then 4 bytes width, 4 bytes height
if len(content) >= 12 {
w := int(binary.BigEndian.Uint32(content[4:8]))
h := int(binary.BigEndian.Uint32(content[8:12]))
if w > 0 && h > 0 {
return w, h, true
}
}
}
offset += boxSize
}
return 0, 0, false
}
// guessMimeTypeFromURL 从 URL 猜测 MIME 类型
func guessMimeTypeFromURL(url string) string {
cleanedURL := url
+2 -2
View File
@@ -37,7 +37,7 @@ func (w *WalletFunding) PreConsume(amount int) error {
if amount <= 0 {
return nil
}
if err := model.DecreaseUserQuota(w.userId, amount); err != nil {
if err := model.DecreaseUserQuota(w.userId, amount, false); err != nil {
return err
}
w.consumed = amount
@@ -49,7 +49,7 @@ func (w *WalletFunding) Settle(delta int) error {
return nil
}
if delta > 0 {
return model.DecreaseUserQuota(w.userId, delta)
return model.DecreaseUserQuota(w.userId, delta, false)
}
return model.IncreaseUserQuota(w.userId, -delta, false)
}
+29 -13
View File
@@ -159,20 +159,36 @@ func DecodeUrlImageData(imageUrl string) (image.Config, string, error) {
}
func getImageConfig(reader io.Reader) (image.Config, string, error) {
// Read all data so we can retry with different decoders
data, readErr := io.ReadAll(reader)
if readErr != nil {
return image.Config{}, "", fmt.Errorf("failed to read image data: %w", readErr)
}
// 读取图片的头部信息来获取图片尺寸
config, format, err := image.DecodeConfig(reader)
if err != nil {
err = errors.New(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error()))
common.SysLog(err.Error())
config, err = webp.DecodeConfig(reader)
if err != nil {
err = errors.New(fmt.Sprintf("fail to decode image config(webp): %s", err.Error()))
common.SysLog(err.Error())
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err == nil {
return config, format, nil
}
common.SysLog(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error()))
config, err = webp.DecodeConfig(bytes.NewReader(data))
if err == nil {
return config, "webp", nil
}
common.SysLog(fmt.Sprintf("fail to decode image config(webp): %s", err.Error()))
// Try HEIF/HEIC: parse ISOBMFF ispe box for dimensions
if heifMime := detectHEIF(data); heifMime != "" {
formatName := "heif"
if heifMime == "image/heic" {
formatName = "heic"
}
format = "webp"
if w, h, ok := parseHEIFDimensions(data); ok {
return image.Config{Width: w, Height: h}, formatName, nil
}
return image.Config{}, "", fmt.Errorf("failed to decode HEIF/HEIC image dimensions")
}
if err != nil {
return image.Config{}, "", err
}
return config, format, nil
return image.Config{}, "", err
}
+1 -1
View File
@@ -381,7 +381,7 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu
} else {
// Wallet
if quota > 0 {
err = model.DecreaseUserQuota(relayInfo.UserId, quota)
err = model.DecreaseUserQuota(relayInfo.UserId, quota, false)
} else {
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
}
+21 -5
View File
@@ -36,8 +36,12 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
}
}
other := make(map[string]interface{})
other["is_task"] = true
other["request_path"] = c.Request.URL.Path
other["model_price"] = info.PriceData.ModelPrice
if info.PriceData.ModelRatio > 0 {
other["model_ratio"] = info.PriceData.ModelRatio
}
other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio
if info.PriceData.GroupRatioInfo.HasSpecialRatio {
other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio
@@ -86,7 +90,7 @@ func taskAdjustFunding(task *model.Task, delta int) error {
return model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta))
}
if delta > 0 {
return model.DecreaseUserQuota(task.UserId, delta)
return model.DecreaseUserQuota(task.UserId, delta, false)
}
return model.IncreaseUserQuota(task.UserId, -delta, false)
}
@@ -117,6 +121,9 @@ func taskBillingOther(task *model.Task) map[string]interface{} {
other := make(map[string]interface{})
if bc := task.PrivateData.BillingContext; bc != nil {
other["model_price"] = bc.ModelPrice
if bc.ModelRatio > 0 {
other["model_ratio"] = bc.ModelRatio
}
other["group_ratio"] = bc.GroupRatio
if len(bc.OtherRatios) > 0 {
for k, v := range bc.OtherRatios {
@@ -222,7 +229,6 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
}
other := taskBillingOther(task)
other["task_id"] = task.TaskID
//other["reason"] = reason
other["pre_consumed_quota"] = preConsumedQuota
other["actual_quota"] = actualQuota
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
@@ -277,9 +283,19 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
finalGroupRatio = groupRatio
}
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio)
// 计算 OtherRatios 乘积(视频折扣、时长等)
otherMultiplier := 1.0
if bc := task.PrivateData.BillingContext; bc != nil {
for _, r := range bc.OtherRatios {
if r != 1.0 && r > 0 {
otherMultiplier *= r
}
}
}
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f", totalTokens, modelRatio, finalGroupRatio)
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * otherMultiplier
actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier)
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, otherMultiplier)
RecalculateTaskQuota(ctx, task, actualQuota, reason)
}
-3
View File
@@ -100,8 +100,6 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea
if err != nil {
return 0, err
}
fileMeta.MimeType = format
if config.Width == 0 || config.Height == 0 {
// not an image, but might be a valid file
if format != "" {
@@ -268,7 +266,6 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela
}
continue
}
file.MimeType = cachedData.MimeType
file.FileType = DetectFileType(cachedData.MimeType)
}
}
+1
View File
@@ -17,6 +17,7 @@ var defaultQwenSettings = QwenSettings{
"z-image",
"qwen-image",
"wan2.6",
"wan2.7",
"qwen-image-edit",
"qwen-image-edit-max",
"qwen-image-edit-max-2026-01-16",
@@ -20,9 +20,10 @@ type ChannelAffinityRule struct {
ParamOverrideTemplate map[string]interface{} `json:"param_override_template,omitempty"`
SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
SkipRetryOnFailure bool `json:"skip_retry_on_failure"`
IncludeUsingGroup bool `json:"include_using_group"`
IncludeModelName bool `json:"include_model_name"`
IncludeRuleName bool `json:"include_rule_name"`
}
+7 -7
View File
@@ -361,6 +361,10 @@ func UpdateModelPriceByJSONString(jsonStr string) error {
func GetModelPrice(name string, printErr bool) (float64, bool) {
name = FormatMatchingModelName(name)
if price, ok := modelPriceMap.Get(name); ok {
return price, true
}
if strings.HasSuffix(name, CompactModelSuffix) {
price, ok := modelPriceMap.Get(CompactWildcardModelKey)
if !ok {
@@ -372,14 +376,10 @@ func GetModelPrice(name string, printErr bool) (float64, bool) {
return price, true
}
price, ok := modelPriceMap.Get(name)
if !ok {
if printErr {
common.SysError("model price not found: " + name)
}
return -1, false
if printErr {
common.SysError("model price not found: " + name)
}
return price, true
return -1, false
}
func UpdateModelRatioByJSONString(jsonStr string) error {
+1 -1
View File
@@ -21,7 +21,7 @@ var defaultFetchSetting = FetchSetting{
DomainList: []string{},
IpList: []string{},
AllowedPorts: []string{"80", "443", "8080", "8443"},
ApplyIPFilterForDomain: false,
ApplyIPFilterForDomain: true,
}
func init() {
+6
View File
@@ -390,6 +390,12 @@ func ErrOptionWithNoRecordErrorLog() NewAPIErrorOptions {
}
}
func ErrOptionWithStatusCode(statusCode int) NewAPIErrorOptions {
return func(e *NewAPIError) {
e.StatusCode = statusCode
}
}
func ErrOptionWithHideErrMsg(replaceStr string) NewAPIErrorOptions {
return func(e *NewAPIError) {
if common.DebugEnabled {
+128 -127
View File
@@ -4,39 +4,144 @@ import (
"fmt"
"image"
"os"
"strings"
"sync"
)
// FileSourceType 文件来源类型
type FileSourceType string
const (
FileSourceTypeURL FileSourceType = "url" // URL 来源
FileSourceTypeBase64 FileSourceType = "base64" // Base64 内联数据
)
// FileSource 统一的文件来源抽象
// FileSource 统一的文件来源抽象接口
// 支持 URL 和 base64 两种来源,提供懒加载和缓存机制
type FileSource struct {
Type FileSourceType `json:"type"` // 来源类型
URL string `json:"url,omitempty"` // URL(当 Type 为 url 时)
Base64Data string `json:"base64_data,omitempty"` // Base64 数据(当 Type 为 base64 时)
MimeType string `json:"mime_type,omitempty"` // MIME 类型(可选,会自动检测)
type FileSource interface {
IsURL() bool
GetIdentifier() string
GetRawData() string
ClearRawData()
// 内部缓存(不导出,不序列化)
SetCache(data *CachedFileData)
GetCache() *CachedFileData
HasCache() bool
ClearCache()
IsRegistered() bool
SetRegistered(registered bool)
Mu() *sync.Mutex
}
// baseFileSource 共享的缓存/锁/清理注册状态
type baseFileSource struct {
cachedData *CachedFileData
cacheLoaded bool
registered bool // 是否已注册到清理列表
mu sync.Mutex // 保护加载过程
registered bool
mu sync.Mutex
}
// Mu 获取内部锁
func (f *FileSource) Mu() *sync.Mutex {
return &f.mu
func (b *baseFileSource) SetCache(data *CachedFileData) {
b.cachedData = data
b.cacheLoaded = true
}
// CachedFileData 缓存的文件数据
// 支持内存缓存和磁盘缓存两种模式
func (b *baseFileSource) GetCache() *CachedFileData {
return b.cachedData
}
func (b *baseFileSource) HasCache() bool {
return b.cacheLoaded && b.cachedData != nil
}
func (b *baseFileSource) ClearCache() {
if b.cachedData != nil {
b.cachedData.Close()
}
b.cachedData = nil
b.cacheLoaded = false
}
func (b *baseFileSource) IsRegistered() bool {
return b.registered
}
func (b *baseFileSource) SetRegistered(registered bool) {
b.registered = registered
}
func (b *baseFileSource) Mu() *sync.Mutex {
return &b.mu
}
// ---------------------------------------------------------------------------
// URLSource — URL 来源的 FileSource 实现
// ---------------------------------------------------------------------------
type URLSource struct {
baseFileSource
URL string
}
func (u *URLSource) IsURL() bool { return true }
func (u *URLSource) GetIdentifier() string {
if len(u.URL) > 100 {
return u.URL[:100] + "..."
}
return u.URL
}
func (u *URLSource) GetRawData() string { return u.URL }
func (u *URLSource) ClearRawData() {}
// ---------------------------------------------------------------------------
// Base64Source — Base64 内联数据来源的 FileSource 实现
// ---------------------------------------------------------------------------
type Base64Source struct {
baseFileSource
Base64Data string
MimeType string
}
func (b *Base64Source) IsURL() bool { return false }
func (b *Base64Source) GetIdentifier() string {
if len(b.Base64Data) > 50 {
return "base64:" + b.Base64Data[:50] + "..."
}
return "base64:" + b.Base64Data
}
func (b *Base64Source) GetRawData() string { return b.Base64Data }
func (b *Base64Source) ClearRawData() {
if len(b.Base64Data) > 1024 {
b.Base64Data = ""
}
}
// ---------------------------------------------------------------------------
// Constructors
// ---------------------------------------------------------------------------
func NewURLFileSource(url string) *URLSource {
return &URLSource{URL: url}
}
func NewBase64FileSource(base64Data string, mimeType string) *Base64Source {
return &Base64Source{
Base64Data: base64Data,
MimeType: mimeType,
}
}
func NewFileSourceFromData(data string, mimeType string) FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return NewURLFileSource(data)
}
return NewBase64FileSource(data, mimeType)
}
// ---------------------------------------------------------------------------
// CachedFileData — 缓存的文件数据(支持内存和磁盘两种模式)
// ---------------------------------------------------------------------------
type CachedFileData struct {
base64Data string // 内存中的 base64 数据(小文件)
MimeType string // MIME 类型
@@ -45,18 +150,15 @@ type CachedFileData struct {
ImageConfig *image.Config // 图片配置(如果是图片)
ImageFormat string // 图片格式(如果是图片)
// 磁盘缓存相关
diskPath string // 磁盘缓存文件路径(大文件)
isDisk bool // 是否使用磁盘缓存
diskMu sync.Mutex // 磁盘操作锁(保护磁盘文件的读取和删除)
diskClosed bool // 是否已关闭/清理
statDecremented bool // 是否已扣减统计
// 统计回调,避免循环依赖
OnClose func(size int64)
}
// NewMemoryCachedData 创建内存缓存的数据
func NewMemoryCachedData(base64Data string, mimeType string, size int64) *CachedFileData {
return &CachedFileData{
base64Data: base64Data,
@@ -66,7 +168,6 @@ func NewMemoryCachedData(base64Data string, mimeType string, size int64) *Cached
}
}
// NewDiskCachedData 创建磁盘缓存的数据
func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFileData {
return &CachedFileData{
diskPath: diskPath,
@@ -76,7 +177,6 @@ func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFile
}
}
// GetBase64Data 获取 base64 数据(自动处理内存/磁盘)
func (c *CachedFileData) GetBase64Data() (string, error) {
if !c.isDisk {
return c.base64Data, nil
@@ -89,7 +189,6 @@ func (c *CachedFileData) GetBase64Data() (string, error) {
return "", fmt.Errorf("disk cache already closed")
}
// 从磁盘读取
data, err := os.ReadFile(c.diskPath)
if err != nil {
return "", fmt.Errorf("failed to read from disk cache: %w", err)
@@ -97,22 +196,19 @@ func (c *CachedFileData) GetBase64Data() (string, error) {
return string(data), nil
}
// SetBase64Data 设置 base64 数据(仅用于内存模式)
func (c *CachedFileData) SetBase64Data(data string) {
if !c.isDisk {
c.base64Data = data
}
}
// IsDisk 是否使用磁盘缓存
func (c *CachedFileData) IsDisk() bool {
return c.isDisk
}
// Close 关闭并清理资源
func (c *CachedFileData) Close() error {
if !c.isDisk {
c.base64Data = "" // 释放内存
c.base64Data = ""
return nil
}
@@ -126,7 +222,6 @@ func (c *CachedFileData) Close() error {
c.diskClosed = true
if c.diskPath != "" {
err := os.Remove(c.diskPath)
// 只有在删除成功且未扣减过统计时,才执行回调
if err == nil && !c.statDecremented && c.OnClose != nil {
c.OnClose(c.DiskSize)
c.statDecremented = true
@@ -135,97 +230,3 @@ func (c *CachedFileData) Close() error {
}
return nil
}
// NewURLFileSource 创建 URL 来源的 FileSource
func NewURLFileSource(url string) *FileSource {
return &FileSource{
Type: FileSourceTypeURL,
URL: url,
}
}
// NewBase64FileSource 创建 base64 来源的 FileSource
func NewBase64FileSource(base64Data string, mimeType string) *FileSource {
return &FileSource{
Type: FileSourceTypeBase64,
Base64Data: base64Data,
MimeType: mimeType,
}
}
// IsURL 判断是否是 URL 来源
func (f *FileSource) IsURL() bool {
return f.Type == FileSourceTypeURL
}
// IsBase64 判断是否是 base64 来源
func (f *FileSource) IsBase64() bool {
return f.Type == FileSourceTypeBase64
}
// GetIdentifier 获取文件标识符(用于日志和错误追踪)
func (f *FileSource) GetIdentifier() string {
if f.IsURL() {
if len(f.URL) > 100 {
return f.URL[:100] + "..."
}
return f.URL
}
if len(f.Base64Data) > 50 {
return "base64:" + f.Base64Data[:50] + "..."
}
return "base64:" + f.Base64Data
}
// GetRawData 获取原始数据(URL 或完整的 base64 字符串)
func (f *FileSource) GetRawData() string {
if f.IsURL() {
return f.URL
}
return f.Base64Data
}
// SetCache 设置缓存数据
func (f *FileSource) SetCache(data *CachedFileData) {
f.cachedData = data
f.cacheLoaded = true
}
// IsRegistered 是否已注册到清理列表
func (f *FileSource) IsRegistered() bool {
return f.registered
}
// SetRegistered 设置注册状态
func (f *FileSource) SetRegistered(registered bool) {
f.registered = registered
}
// GetCache 获取缓存数据
func (f *FileSource) GetCache() *CachedFileData {
return f.cachedData
}
// HasCache 是否有缓存
func (f *FileSource) HasCache() bool {
return f.cacheLoaded && f.cachedData != nil
}
// ClearCache 清除缓存,释放内存和磁盘文件
func (f *FileSource) ClearCache() {
// 如果有缓存数据,先关闭它(会清理磁盘文件)
if f.cachedData != nil {
f.cachedData.Close()
}
f.cachedData = nil
f.cacheLoaded = false
}
// ClearRawData 清除原始数据,只保留必要的元信息
// 用于在处理完成后释放大文件的内存
func (f *FileSource) ClearRawData() {
// 保留 URL(通常很短),只清除大的 base64 数据
if f.IsBase64() && len(f.Base64Data) > 1024 {
f.Base64Data = ""
}
}
+4 -5
View File
@@ -32,13 +32,12 @@ type TokenCountMeta struct {
type FileMeta struct {
FileType
MimeType string
Source *FileSource // 统一的文件来源(URL 或 base64
Detail string // 图片细节级别(low/high/auto
Source FileSource // 统一的文件来源(URL 或 base64)
Detail string // 图片细节级别(low/high/auto
}
// NewFileMeta 创建新的 FileMeta
func NewFileMeta(fileType FileType, source *FileSource) *FileMeta {
func NewFileMeta(fileType FileType, source FileSource) *FileMeta {
return &FileMeta{
FileType: fileType,
Source: source,
@@ -46,7 +45,7 @@ func NewFileMeta(fileType FileType, source *FileSource) *FileMeta {
}
// NewImageFileMeta 创建图片类型的 FileMeta
func NewImageFileMeta(source *FileSource, detail string) *FileMeta {
func NewImageFileMeta(source FileSource, detail string) *FileMeta {
return &FileMeta{
FileType: FileTypeImage,
Source: source,
+5 -4
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "react-template",
@@ -10,7 +11,7 @@
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"axios": "1.12.0",
"axios": "1.13.5",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"history": "^5.3.0",
@@ -776,7 +777,7 @@
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
"axios": ["axios@1.12.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg=="],
"axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
@@ -1104,13 +1105,13 @@
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { API, showError } from '../../../helpers';
import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui';
const { Title } = Typography;
@@ -28,7 +28,7 @@ import {
import { useTranslation } from 'react-i18next';
import MarkdownRenderer from '../markdown/MarkdownRenderer';
// URL
// Check whether content is a URL.
const isUrl = (content) => {
try {
new URL(content.trim());
@@ -38,27 +38,23 @@ const isUrl = (content) => {
}
};
// HTML
// Check whether content contains HTML.
const isHtmlContent = (content) => {
if (!content || typeof content !== 'string') return false;
// HTML
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
return htmlTagRegex.test(content);
};
// HTML
// Parse HTML content and extract inline styles.
const sanitizeHtml = (html) => {
// HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
//
const styles = Array.from(tempDiv.querySelectorAll('style'))
.map((style) => style.innerHTML)
.join('\n');
// bodybody使
const bodyContent = tempDiv.querySelector('body');
const content = bodyContent ? bodyContent.innerHTML : html;
@@ -76,15 +72,11 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const { t } = useTranslation();
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [htmlStyles, setHtmlStyles] = useState('');
const [processedHtmlContent, setProcessedHtmlContent] = useState('');
const loadContent = async () => {
//
const cachedContent = localStorage.getItem(cacheKey) || '';
if (cachedContent) {
setContent(cachedContent);
processContent(cachedContent);
setLoading(false);
}
@@ -93,7 +85,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const { success, message, data } = res.data;
if (success && data) {
setContent(data);
processContent(data);
localStorage.setItem(cacheKey, data);
} else {
if (!cachedContent) {
@@ -111,16 +102,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
}
};
const processContent = (rawContent) => {
if (isHtmlContent(rawContent)) {
const { content: htmlContent, styles } = sanitizeHtml(rawContent);
setProcessedHtmlContent(htmlContent);
setHtmlStyles(styles);
} else {
setProcessedHtmlContent('');
setHtmlStyles('');
const htmlPayload = useMemo(() => {
if (!isHtmlContent(content)) {
return { content: '', styles: '' };
}
};
return sanitizeHtml(content);
}, [content]);
useEffect(() => {
loadContent();
@@ -129,8 +116,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// HTML
useEffect(() => {
const styleId = `document-renderer-styles-${cacheKey}`;
const { styles } = htmlPayload;
if (htmlStyles) {
if (styles) {
let styleEl = document.getElementById(styleId);
if (!styleEl) {
styleEl = document.createElement('style');
@@ -138,7 +126,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
styleEl.type = 'text/css';
document.head.appendChild(styleEl);
}
styleEl.innerHTML = htmlStyles;
styleEl.innerHTML = styles;
} else {
const el = document.getElementById(styleId);
if (el) el.remove();
@@ -148,7 +136,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const el = document.getElementById(styleId);
if (el) el.remove();
};
}, [htmlStyles, cacheKey]);
}, [cacheKey, htmlPayload]);
//
if (loading) {
@@ -207,15 +195,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// HTML
if (isHtmlContent(content)) {
const { content: htmlContent, styles } = sanitizeHtml(content);
//
useEffect(() => {
if (styles && styles !== htmlStyles) {
setHtmlStyles(styles);
}
}, [content, styles, htmlStyles]);
return (
<div className='min-h-screen bg-gray-50'>
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
@@ -225,7 +204,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
</Title>
<div
className='prose prose-lg max-w-none'
dangerouslySetInnerHTML={{ __html: htmlContent }}
dangerouslySetInnerHTML={{ __html: htmlPayload.content }}
/>
</div>
</div>
@@ -0,0 +1,52 @@
import React from 'react';
import { Empty, Button } from '@douyinfe/semi-ui';
import {
IllustrationFailure,
IllustrationFailureDark,
} from '@douyinfe/semi-illustrations';
import { withTranslation } from 'react-i18next';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('[ErrorBoundary]', error, errorInfo);
}
render() {
if (this.state.hasError) {
const { t } = this.props;
return (
<div className='flex flex-col justify-center items-center h-screen p-8'>
<Empty
image={
<IllustrationFailure style={{ width: 250, height: 250 }} />
}
darkModeImage={
<IllustrationFailureDark style={{ width: 250, height: 250 }} />
}
description={t('页面渲染出错,请刷新页面重试')}
/>
<Button
theme='solid'
type='primary'
style={{ marginTop: 16 }}
onClick={() => window.location.reload()}
>
{t('刷新页面')}
</Button>
</div>
);
}
return this.props.children;
}
}
export default withTranslation()(ErrorBoundary);
+13 -6
View File
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
import { Server, Gauge, ExternalLink } from 'lucide-react';
import { Server, Gauge, ExternalLink, Copy } from 'lucide-react';
import {
IllustrationConstruction,
IllustrationConstructionDark,
@@ -87,11 +87,18 @@ const ApiInfoPanel = ({
</Tag>
</div>
</div>
<div
className='!text-semi-color-primary break-all cursor-pointer hover:underline mb-1'
onClick={() => handleCopyUrl(api.url)}
>
{api.url}
<div className='flex items-center gap-1 mb-1'>
<span
className='!text-semi-color-primary break-all cursor-pointer hover:underline'
onClick={() => handleCopyUrl(api.url)}
>
{api.url}
</span>
<Copy
size={14}
className='flex-shrink-0 text-gray-400 hover:text-semi-color-primary cursor-pointer transition-colors'
onClick={() => handleCopyUrl(api.url)}
/>
</div>
<div className='text-gray-500'>{api.description}</div>
</div>
+16 -1
View File
@@ -29,6 +29,9 @@ const ChartsPanel = ({
spec_model_line,
spec_pie,
spec_rank_bar,
spec_user_rank,
spec_user_trend,
isAdminUser,
CARD_PROPS,
CHART_CONFIG,
FLEX_CENTER_GAP2,
@@ -51,9 +54,15 @@ const ChartsPanel = ({
onChange={setActiveChartTab}
>
<TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
<TabPane tab={<span>{t('消耗趋势')}</span>} itemKey='2' />
<TabPane tab={<span>{t('调用趋势')}</span>} itemKey='2' />
<TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
<TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
{isAdminUser && (
<TabPane tab={<span>{t('用户消耗排行')}</span>} itemKey='5' />
)}
{isAdminUser && (
<TabPane tab={<span>{t('用户消耗趋势')}</span>} itemKey='6' />
)}
</Tabs>
</div>
}
@@ -72,6 +81,12 @@ const ChartsPanel = ({
{activeChartTab === '4' && (
<VChart spec={spec_rank_bar} option={CHART_CONFIG} />
)}
{activeChartTab === '5' && isAdminUser && (
<VChart spec={spec_user_rank} option={CHART_CONFIG} />
)}
{activeChartTab === '6' && isAdminUser && (
<VChart spec={spec_user_trend} option={CHART_CONFIG} />
)}
</div>
</Card>
);
+15
View File
@@ -86,12 +86,22 @@ const Dashboard = () => {
);
// ========== ==========
const loadUserData = async () => {
if (dashboardData.isAdminUser) {
const userData = await dashboardData.loadUserQuotaData();
if (userData && userData.length > 0) {
dashboardCharts.updateUserChartData(userData);
}
}
};
const initChart = async () => {
await dashboardData.loadQuotaData().then((data) => {
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
});
await loadUserData();
await dashboardData.loadUptimeData();
};
@@ -100,10 +110,12 @@ const Dashboard = () => {
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
await loadUserData();
};
const handleSearchConfirm = async () => {
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
await loadUserData();
};
// ========== ==========
@@ -182,6 +194,9 @@ const Dashboard = () => {
spec_model_line={dashboardCharts.spec_model_line}
spec_pie={dashboardCharts.spec_pie}
spec_rank_bar={dashboardCharts.spec_rank_bar}
spec_user_rank={dashboardCharts.spec_user_rank}
spec_user_trend={dashboardCharts.spec_user_trend}
isAdminUser={dashboardData.isAdminUser}
CARD_PROPS={CARD_PROPS}
CHART_CONFIG={CHART_CONFIG}
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
+20 -16
View File
@@ -221,23 +221,27 @@ const FooterBar = () => {
return (
<div className='w-full'>
{footer ? (
<div className='relative'>
<div
className='custom-footer'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
<div className='absolute bottom-2 right-4 text-xs !text-semi-color-text-2 opacity-70'>
<span>{t('设计与开发由')} </span>
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-primary font-medium'
>
New API
</a>
<footer className='relative h-auto py-4 px-6 md:px-24 w-full flex items-center justify-center overflow-hidden'>
<div className='flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-4'>
<div
className='custom-footer na-cb6feafeb3990c78 text-sm !text-semi-color-text-1'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
<div className='text-sm flex-shrink-0'>
<span className='!text-semi-color-text-1'>
{t('设计与开发由')}{' '}
</span>
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-primary font-medium'
>
New API
</a>
</div>
</div>
</div>
</footer>
) : (
customFooter
)}
+4 -1
View File
@@ -23,6 +23,7 @@ import SiderBar from './SiderBar';
import App from '../../App';
import FooterBar from './Footer';
import { ToastContainer } from 'react-toastify';
import ErrorBoundary from '../common/ErrorBoundary';
import React, { useContext, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/common/useIsMobile';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
@@ -216,7 +217,9 @@ const PageLayout = () => {
position: 'relative',
}}
>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</Content>
{!shouldHideFooter && (
<Layout.Footer
@@ -95,13 +95,15 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
</Dropdown.Menu>
}
>
<Button
icon={currentButtonIcon}
aria-label={t('切换主题')}
theme='borderless'
type='tertiary'
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
/>
<span className='inline-flex'>
<Button
icon={currentButtonIcon}
aria-label={t('切换主题')}
theme='borderless'
type='tertiary'
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
/>
</span>
</Dropdown>
);
};
@@ -21,8 +21,9 @@ import React, { useRef, useEffect } from 'react';
import { Typography, TextArea, Button } from '@douyinfe/semi-ui';
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
import ThinkingContent from './ThinkingContent';
import { Loader2, Check, X } from 'lucide-react';
import { Loader2, Check, X, Settings, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isAdmin } from '../../helpers/utils';
const MessageContent = ({
message,
@@ -64,6 +65,44 @@ const MessageContent = ({
errorText = t('请求发生错误');
}
if (message.errorCode === 'model_price_error') {
return (
<div className={`${className}`}>
<div
className='rounded-lg p-3 space-y-2'
style={{
background: 'var(--semi-color-bg-0)',
border: '1px solid var(--semi-color-border)',
}}
>
<div className='flex items-center gap-2'>
<AlertTriangle size={16} className='text-orange-500 shrink-0' />
<Typography.Text strong className='!text-[var(--semi-color-text-0)]'>
{t('模型价格未配置')}
</Typography.Text>
</div>
<Typography.Paragraph
className='!text-[var(--semi-color-text-1)] !text-sm !mb-0'
style={{ wordBreak: 'break-word' }}
>
{errorText}
</Typography.Paragraph>
{isAdmin() && (
<Button
size='small'
theme='light'
type='warning'
icon={<Settings size={14} />}
onClick={() => window.open('/console/setting?tab=ratio', '_blank')}
>
{t('前往设置')}
</Button>
)}
</div>
</div>
);
}
return (
<div className={`${className}`}>
<Typography.Text className='text-white'>{errorText}</Typography.Text>
@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Input, Slider, Typography, Button, Tag } from '@douyinfe/semi-ui';
import {
Input,
InputNumber,
Slider,
Typography,
Button,
Tag,
} from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import {
Hash,
@@ -241,15 +248,14 @@ const ParameterControl = ({
disabled={disabled}
/>
</div>
<Input
<InputNumber
placeholder='MaxTokens'
name='max_tokens'
required
autoComplete='new-password'
defaultValue={0}
value={inputs.max_tokens}
onChange={(value) => onInputChange('max_tokens', value)}
className='!rounded-lg'
onNumberChange={(value) => onInputChange('max_tokens', value)}
min={0}
precision={0}
style={{ width: '100%' }}
disabled={!parameterEnabled.max_tokens || disabled}
/>
</div>
@@ -65,11 +65,15 @@ export const loadConfig = () => {
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
const parsedMaxTokens = parseInt(parsedConfig?.inputs?.max_tokens, 10);
const mergedConfig = {
inputs: {
...DEFAULT_CONFIG.inputs,
...parsedConfig.inputs,
max_tokens: Number.isNaN(parsedMaxTokens)
? parsedConfig?.inputs?.max_tokens
: parsedMaxTokens,
},
parameterEnabled: {
...DEFAULT_CONFIG.parameterEnabled,
@@ -306,9 +306,9 @@ const PersonalSetting = () => {
const bindWeChat = async () => {
if (inputs.wechat_verification_code === '') return;
const res = await API.get(
`/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`,
);
const res = await API.post('/api/oauth/wechat/bind', {
code: inputs.wechat_verification_code,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('微信账户绑定成功!'));
@@ -378,9 +378,10 @@ const PersonalSetting = () => {
return;
}
setLoading(true);
const res = await API.get(
`/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`,
);
const res = await API.post('/api/oauth/email/bind', {
email: inputs.email,
code: inputs.email_verification_code,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('邮箱账户绑定成功!'));
+4 -9
View File
@@ -21,9 +21,8 @@ import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import ModelPricingCombined from '../../pages/Setting/Ratio/ModelPricingCombined';
import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings';
import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings';
import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor';
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor';
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync';
@@ -95,18 +94,14 @@ const RatioSetting = () => {
return (
<Spin spinning={loading} size='large'>
{/* 模型倍率设置以及价格编辑器 */}
<Card style={{ marginTop: '10px' }}>
<Tabs type='card' defaultActiveKey='visual'>
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
<ModelRatioSettings options={inputs} refresh={onRefresh} />
<Tabs type='card' defaultActiveKey='pricing'>
<Tabs.TabPane tab={t('模型定价设置')} itemKey='pricing'>
<ModelPricingCombined options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('分组相关设置')} itemKey='group'>
<GroupRatioSettings options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('价格设置')} itemKey='visual'>
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'>
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
+13 -2
View File
@@ -91,6 +91,7 @@ const SystemSetting = () => {
EmailDomainRestrictionEnabled: '',
EmailAliasRestrictionEnabled: '',
SMTPSSLEnabled: '',
SMTPForceAuthLogin: '',
EmailDomainWhitelist: [],
TelegramOAuthEnabled: '',
TelegramBotToken: '',
@@ -108,7 +109,7 @@ const SystemSetting = () => {
'fetch_setting.domain_list': [],
'fetch_setting.ip_list': [],
'fetch_setting.allowed_ports': [],
'fetch_setting.apply_ip_filter_for_domain': false,
'fetch_setting.apply_ip_filter_for_domain': true,
});
const [originInputs, setOriginInputs] = useState({});
@@ -182,6 +183,7 @@ const SystemSetting = () => {
case 'EmailDomainRestrictionEnabled':
case 'EmailAliasRestrictionEnabled':
case 'SMTPSSLEnabled':
case 'SMTPForceAuthLogin':
case 'LinuxDOOAuthEnabled':
case 'discord.enabled':
case 'oidc.enabled':
@@ -847,7 +849,7 @@ const SystemSetting = () => {
}
style={{ marginBottom: 8 }}
>
{t('对域名启用 IP 过滤(实验性')}
{t('对域名启用 IP 过滤(推荐开启')}
</Form.Checkbox>
<Text strong>
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
@@ -1335,6 +1337,15 @@ const SystemSetting = () => {
>
{t('启用SMTP SSL')}
</Form.Checkbox>
<Form.Checkbox
field='SMTPForceAuthLogin'
noLabel
onChange={(e) =>
handleCheckboxChange('SMTPForceAuthLogin', e)
}
>
{t('强制使用 AUTH LOGIN')}
</Form.Checkbox>
</Col>
</Row>
<Button onClick={submitSMTP}>{t('保存 SMTP 设置')}</Button>
@@ -67,6 +67,7 @@ import SecureVerificationModal from '../../../common/modals/SecureVerificationMo
import StatusCodeRiskGuardModal from './StatusCodeRiskGuardModal';
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
import { useSecureVerification } from '../../../../hooks/common/useSecureVerification';
import { parseChannelConnectionString } from '../../../../helpers/token';
import { createApiCalls } from '../../../../services/secureVerification';
import {
collectInvalidStatusCodeEntries,
@@ -102,6 +103,7 @@ const REGION_EXAMPLE = {
'claude-3-5-sonnet-20240620': 'europe-west1',
};
const UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT = 8;
const ADVANCED_SETTINGS_EXPANDED_KEY = 'channel-advanced-settings-expanded';
const PARAM_OVERRIDE_LEGACY_TEMPLATE = {
temperature: 0,
@@ -398,8 +400,15 @@ const EditChannelModal = (props) => {
[],
);
//
const [clipboardConfig, setClipboardConfig] = useState(null);
//
const [advancedSettingsOpen, setAdvancedSettingsOpen] = useState(false);
const toggleAdvancedSettings = (open) => {
setAdvancedSettingsOpen(open);
localStorage.setItem(ADVANCED_SETTINGS_EXPANDED_KEY, String(open));
};
const formContainerRef = useRef(null);
const doubaoApiClickCountRef = useRef(0);
const initialBaseUrlRef = useRef('');
@@ -538,6 +547,39 @@ const EditChannelModal = (props) => {
handleInputChange('settings', settingsJson);
};
const applyClipboardConfig = (config) => {
if (!config) return;
setInputs((prev) => ({
...prev,
key: config.key,
base_url: config.url,
}));
if (formApiRef.current) {
formApiRef.current.setValue('key', config.key);
formApiRef.current.setValue('base_url', config.url);
}
setClipboardConfig(null);
showSuccess(t('连接信息已填入'));
};
const pasteFromClipboard = async () => {
if (!navigator?.clipboard?.readText) {
showError(t('无法读取剪贴板'));
return;
}
try {
const text = await navigator.clipboard.readText();
const parsed = parseChannelConnectionString(text);
if (parsed) {
applyClipboardConfig(parsed);
} else {
showInfo(t('剪贴板中未检测到连接信息'));
}
} catch {
showError(t('无法读取剪贴板'));
}
};
const isIonetLocked = isIonetChannel && isEdit;
const handleInputChange = (name, value) => {
@@ -1269,12 +1311,22 @@ const EditChannelModal = (props) => {
loadChannel();
} else {
formApiRef.current?.setValues(getInitValues());
try {
navigator?.clipboard?.readText()?.then((text) => {
const parsed = parseChannelConnectionString(text);
if (parsed) {
setClipboardConfig(parsed);
}
}).catch(() => {});
} catch {}
}
fetchModelGroups();
//
setUseManualInput(false);
//
setAdvancedSettingsOpen(false);
//
setAdvancedSettingsOpen(
isEdit && localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true'
);
} else {
//
resetModalState();
@@ -1329,6 +1381,8 @@ const EditChannelModal = (props) => {
setInputs(getInitValues());
//
resetKeyDisplayState();
//
setClipboardConfig(null);
};
const handleVertexUploadChange = ({ fileList }) => {
@@ -2077,14 +2131,27 @@ const EditChannelModal = (props) => {
<SideSheet
placement={isEdit ? 'right' : 'left'}
title={
<Space>
<Tag color='blue' shape='circle'>
{isEdit ? t('编辑') : t('新建')}
</Tag>
<Title heading={4} className='m-0'>
{isEdit ? t('更新渠道信息') : t('创建新的渠道')}
</Title>
</Space>
<div className='flex items-center justify-between w-full'>
<Space>
<Tag color='blue' shape='circle'>
{isEdit ? t('编辑') : t('新建')}
</Tag>
<Title heading={4} className='m-0'>
{isEdit ? t('更新渠道信息') : t('创建新的渠道')}
</Title>
</Space>
{!isEdit && (
<Button
size='small'
type='tertiary'
className='ec-dbcd0a3c01b55203 shrink-0'
icon={<IconBolt />}
onClick={pasteFromClipboard}
>
{t('从剪贴板粘贴配置')}
</Button>
)}
</div>
}
bodyStyle={{ padding: '0' }}
visible={props.visible}
@@ -2446,6 +2513,34 @@ const EditChannelModal = (props) => {
<>
<Spin spinning={loading}>
<div className='p-2 space-y-3' ref={formContainerRef}>
{!isEdit && clipboardConfig && (
<Banner
type='info'
className='ec-dbcd0a3c01b55203'
description={
<div className='flex items-center justify-between gap-2'>
<span>{t('检测到剪贴板中的连接信息')}</span>
<div className='flex gap-1'>
<Button
size='small'
theme='solid'
type='primary'
onClick={() => applyClipboardConfig(clipboardConfig)}
>
{t('自动填入')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => setClipboardConfig(null)}
>
{t('忽略')}
</Button>
</div>
</div>
}
/>
)}
{/* Core Configuration Card - Always Visible */}
<Card className='!rounded-2xl shadow-sm border-0'>
{/* Header */}
@@ -3548,7 +3643,7 @@ const EditChannelModal = (props) => {
{isMobile ? (
<Collapse
activeKey={advancedSettingsOpen ? ['advanced'] : []}
onChange={(keys) => setAdvancedSettingsOpen(keys.includes('advanced'))}
onChange={(keys) => toggleAdvancedSettings(keys.includes('advanced'))}
>
<Collapse.Panel
header={
@@ -3570,7 +3665,7 @@ const EditChannelModal = (props) => {
backgroundColor: advancedSettingsOpen ? 'var(--semi-color-primary-light-default)' : 'var(--semi-color-fill-0)',
border: '1px solid var(--semi-color-fill-2)',
}}
onClick={() => setAdvancedSettingsOpen(!advancedSettingsOpen)}
onClick={() => toggleAdvancedSettings(!advancedSettingsOpen)}
>
<div className='flex items-center gap-2'>
<IconSetting size={16} />

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