Compare commits

...

56 Commits

Author SHA1 Message Date
CaIon c29d80f015 chore: remove channel cache test 2026-05-23 13:23:32 +08:00
t0ng7u 3ba01a7dcd 🐛 fix(channel): evict auto-disabled multi-key channels from cache
Ensure multi-key channels are removed from the in-memory routing cache when all keys become auto-disabled, preventing subsequent requests from repeatedly selecting channels with no available keys.

Also make multi-key status updates more robust by handling missing key matches, checking actual enabled key availability, and restoring the channel status when a key is re-enabled. Add regression coverage for disabled cached channels and multi-key cache eviction.
2026-05-20 12:47:46 +08:00
Seefs 20d3e73734 fix: filter perf metrics summary by active groups (#4976) 2026-05-20 11:38:09 +08:00
Seefs 2d1ca15384 fix: respect dashboard content visibility settings (#4975) 2026-05-19 18:46:21 +08:00
Seefs 0d4b25795a fix: expose param override audits for sensitive message fields (#4974) 2026-05-19 18:28:03 +08:00
Calcium-Ion 146dd77b83 fix(keys): call submit handler directly to avoid stale form linkage (#4858) (#4967)
Users reported that the API key edit drawer's "Save changes" button
becomes unresponsive after the drawer has been open / idle for a
while: no loading state, no request, no error. Reopening the drawer
restores it because a fresh DOM is created.

The button lived in `SheetFooter` (a portaled Base UI Sheet) and was
linked to the form via the HTML `form='api-key-form'` attribute. Once
the portal/DOM relationship goes stale, the click no longer triggers
the form's submit event, hence the silent failure.

Defensive fix: drop the cross-DOM `form` linkage and call
`form.handleSubmit(onSubmit)` directly via `onClick`. The native
submit path (Enter key, original `<form onSubmit>`) is preserved.

Closes #4858
2026-05-19 16:40:11 +08:00
Calcium-Ion 5e88f97ac1 fix(data-table): make faceted filter popover width adaptive (#4905) (#4966)
The faceted filter popover used a fixed width of 200px, which clipped
long option labels (e.g. user-defined channel group names) and forced
the truncated text to be unreadable without leaving a way to see the
full value.

- Switch PopoverContent from `w-[200px]` to
  `min-w-[200px] max-w-[360px]` so short option lists keep their
  current footprint while long labels can expand up to 360px before
  the existing truncate kicks in.
- Add `title={t(option.label)}` on the truncated label span so users
  can still hover to see the full text on extreme cases.

Closes #4905
2026-05-19 16:39:57 +08:00
Calcium-Ion 0cd9a3a068 fix(auth): use aff_code field name in registration payload (#4945) (#4965)
The new UI's sign-up form sent the invite code under key `aff`, but
the backend `Register` controller binds it to `User.AffCode` whose
JSON tag is `aff_code` (see model/user.go). Result: every invited
sign-up landed with `inviter_id = 0`, breaking the affiliate flow.

Rename only the request payload field so it matches the backend
contract. URL query parameter (`/sign-up?aff=...`), localStorage key
and OAuth state continue to use `aff` and are unchanged.

Closes #4945
2026-05-19 16:39:42 +08:00
Micah-Zheng 032993ed49 fix: check save result in handleSaveAll and add slate to validColors (#4823)
Signed-off-by: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com>
2026-05-19 16:15:13 +08:00
Micah-Zheng c78573ce03 fix(web/default): api-info color dot shows wrong color due to semantic token mismatch (#4824)
* fix: unify color system for api-info, add slate to SemanticColor

Signed-off-by: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com>

* fix: use direct Tailwind color classes in colorToBgClass for accurate color display

Signed-off-by: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com>

---------

Signed-off-by: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com>
2026-05-19 16:15:02 +08:00
panxinyu 8db32213e7 fix(web/default/wallet): make recharge preset selection visible in dark mode (#4897)
Selected preset buttons looked identical to unselected in dark mode: the
override classes `border-foreground bg-foreground/5` carry no `dark:`
variant, while the Button `outline` variant base contains
`dark:border-input dark:bg-input/30`. tailwind-merge keeps both (different
variants → no conflict), and in dark mode CSS specificity makes
`.dark .border-input` win over `.border-foreground`, so the override is
silently overridden and the bright-border/tinted-bg selection state never
applies.

Add explicit `dark:border-foreground dark:bg-foreground/10` to the
override so tailwind-merge resolves the dark-variant conflict in favor
of the override and the selected state is clearly distinguishable on
both light and dark backgrounds.

Co-authored-by: xinnyu <xinnyu@users.noreply.github.com>
2026-05-19 16:14:56 +08:00
Neo cb9270ed23 fix(auth): localize reset password confirmation (#4769)
* fix(auth): localize reset password confirmation

Wrap reset confirmation page copy in frontend i18n calls and add matching locale entries so the page no longer mixes translated labels with hardcoded English copy.

* fix(auth): use semantic reset i18n keys
2026-05-19 16:14:49 +08:00
Ellis Fan fc08c133e2 fix(web/default): update pagination button labels in ModelCardGrid (#4675)
Change 'Previous' to 'Previous page' and 'Next' to 'Next page'
for improved clarity in the ModelCardGrid component.
2026-05-19 16:14:37 +08:00
Yuhan Guo丨Eohan b397c58bab fix(auth): expose register_enabled in /api/status and gate sign-up link (#4871)
/api/status never returned `register_enabled` or `password_register_enabled`,
so the sign-in page had no way to react when an admin disabled registration.
The "Sign up" link was only gated on `self_use_mode_enabled`, which is a
separate and unrelated concept (single-user vs. multi-user deployment).

Result: toggling "Registration Enabled" in admin settings had no visible
effect on the login page — users could still see the sign-up link even when
registration was disabled, and could not see it even when it was enabled
(if the system happened to be in self-use mode from initial setup).

Fix:
- Add `register_enabled` and `password_register_enabled` to GetStatus()
- Gate the "Sign up" link on `register_enabled !== false` in addition to
  the existing `!self_use_mode_enabled` check

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:14:34 +08:00
Baiyuan Chiu 8ae095c3b8 fix user create and delete handling (#4818) 2026-05-19 16:14:11 +08:00
yyhhyyyyyy 04b4483d7d fix(web): normalize model detail tabs layout (#4938) 2026-05-19 16:14:08 +08:00
Li Duoyang ee9736bbc8 fix: add type="submit" to forgot password form button (#4910)
The "Send reset email" button was missing type="submit", preventing
form submission when clicked. All other auth forms (sign-in, sign-up,
OTP) already have this attribute set correctly.

Closes #4793
2026-05-19 16:14:03 +08:00
Seefs 0936e25046 perf: avoid eager formatting in debug log calls (#4929) 2026-05-19 12:11:24 +08:00
NitroFire 5dd0d3bcbd fix: add analytics placeholder (#4928) 2026-05-17 18:54:39 +08:00
QuentinHsu f69ceb6967 fix: 修复新 UI 语言与文案显示问题 (#4876)
* chore(dev): add local setup state reset target

- add a reset-setup make target to clear setup records, root users, and related options.
- support both docker dev PostgreSQL and local SQLite development databases.
- restart the docker dev backend so setup status is recalculated after reset.

* fix(chat): prevent preset menu text overflow

- add truncation layout for chat preset names to keep long labels inside the sidebar menu.
- prevent loading and external-link icons from shrinking in constrained menu rows.

* fix(i18n): translate dashboard granularity options

- call t() for granularity option labels in dashboard system settings.
- keep localized text consistent between the select trigger and dropdown items.

* chore(dev): add backend dev service rebuild target

- add a dev-api-rebuild make target to rebuild and start the docker backend service.
- reuse DEV_COMPOSE_FILE and DEV_BACKEND_SERVICE variables to avoid repeated compose config literals.

* fix(i18n): align interface language option labels

- add shared interface language options to keep display names consistent.
- reuse the shared options in the header switcher and profile preferences.
- normalize language codes so zh-CN and zh_CN resolve to Simplified Chinese.

* fix(i18n): add missing frontend translation keys

- route channel key prompts, form validation messages, and channel fallback text through i18n.
- add missing translations across six locales for channels, rankings, billing, and logs.
- update i18n sync reports so literal t() keys are present in the base locale.
2026-05-17 11:45:27 +08:00
Seefs 68830e6097 feat: support request_header key source (#4903)
* feat: support request_header key source in backend and settings UI

* feat: support request_header channel affinity source
2026-05-17 11:44:38 +08:00
yyhhyyyyyy 2d968c3eab fix: apply group filter to channel list queries (#4885) 2026-05-17 11:44:07 +08:00
Seefs cb7a61466e Merge pull request #4684 from SAY-5/fix/perf-metric-ambiguous-column
fix: qualify column names in PerfMetric upsert to avoid PG ambiguity
2026-05-16 22:11:38 +08:00
DraftGo 132d7b9f94 fix: GetAllChannels ignores group filter parameter (#4847)
When users filter channels by group without entering a search keyword,
the frontend calls GetAllChannels (GET /api/channel/) instead of
SearchChannels. However, GetAllChannels did not process the group
query parameter, causing the filter to have no effect.

Added group filtering logic to GetAllChannels for both normal mode
and tag mode, using the same CONCAT/|| pattern as SearchChannels
for cross-database compatibility (MySQL, PostgreSQL, SQLite).
2026-05-16 14:54:50 +08:00
yyhhyyyyyy 6f8668e4c3 fix: enforce header nav access control for public modules (#4889) 2026-05-16 14:54:47 +08:00
yyhhyyyyyy 8a10dedb7d fix(web): handle unlimited API key quota validation (#4881) 2026-05-16 14:54:35 +08:00
yyhhyyyyyy 554defe4f4 fix: correct usage logs filtering (#4883) 2026-05-16 14:54:23 +08:00
yyhhyyyyyy 8f9ee9ba88 fix: allow clearing channel remark (#4886) 2026-05-16 14:54:18 +08:00
CaIon 3caa6e467b fix(web/default): batch fix new UI issues #4880 #4893 #4817 #4877 #4898
- Add singleSelect to status/role filters in API keys, users, and redemptions tables (#4880)
- Fix affiliate link 404 by changing /register to /sign-up (#4893)
- Open FetchModelsDialog in channel creation mode via customFetcher prop (#4817)
- Add TruncatedText component with tooltip for long channel names, token names, and usernames (#4877)
- Elevate forgot-password link z-index to prevent label click interception (#4898)
2026-05-16 14:48:49 +08:00
CaIon 18282e610d chore(deps): update axios from 1.15.0 to 1.15.2 2026-05-13 22:23:45 +08:00
skynono 51b5cbe1bd fix: prevent combobox from over-filtering options on focus (#4829) 2026-05-13 22:21:24 +08:00
dependabot[bot] 3e588b4d4f chore(deps-dev): bump ip-address from 10.1.0 to 10.2.0 in /electron (#4811)
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.1.0 to 10.2.0.
- [Commits](https://github.com/beaugunderson/ip-address/commits)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.2.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 22:21:03 +08:00
CaIon 0526a22643 feat: require compliance confirmation for paid features
Gate payment, redemption, subscription, and invitation reward flows behind an audited compliance acknowledgement.
2026-05-13 22:18:46 +08:00
CaIon aa56667b8f feat: track upstream request ID and prevent response header override
When proxying through another new-api instance, the upstream
X-Oneapi-Request-Id was overwriting the local one in client responses.
This adds a new `upstream_request_id` field to the logs table, captures
the upstream ID during relay, and filters it from being copied back to
the client. Frontend gains search/filter and detail display support.
2026-05-12 21:53:54 +08:00
CaIon 428e3d91f2 chore: refresh related resources 2026-05-12 21:53:54 +08:00
dependabot[bot] 3856b9d2c0 chore(deps): bump axios from 1.15.0 to 1.15.2 in /web/classic (#4634)
Bumps [axios](https://github.com/axios/axios) from 1.15.0 to 1.15.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.15.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-12 16:54:30 +08:00
Calcium-Ion 469d3747af fix: defaut ui triage (#4802)
* fix: theme-aware payment paths, auto-group validation, route guards, perf group filtering

- Add common.ThemeAwarePath to generate correct redirect URLs based on
  active theme (default vs classic), replacing hardcoded /console/* paths
  in 7 controllers and service/quota.go (#4765)
- Validate auto-group availability against getUserGroups before defaulting
  form values; playground falls back to 'default' group when 'auto' is
  unavailable (#4796, #4799)
- Enforce HeaderNavModules settings in rankings route (frontend + backend
  API) and SidebarModulesAdmin in playground route to block direct URL
  access when features are disabled (#4704, #4512)
- Filter perf_metrics API response to only include currently configured
  groups, hiding stale data from deleted groups (#4790)
- Preserve query params (pay=success/fail) in /console/topup → /wallet
  frontend redirect

* fix: update hero section text and localization strings for clarity
2026-05-12 16:47:02 +08:00
CaIon a720064d91 Merge branch 'main' of github.com:QuantumNous/new-api 2026-05-12 16:24:00 +08:00
skynono fde2cac9d3 fix(web/default): guard playground messages against legacy classic shape (#4650)
fix(playground): handle legacy localStorage message shape

Sanitizes old-format saved messages to prevent 500 on playground load.
2026-05-12 16:23:33 +08:00
Li Duoyang 2b89989f62 fix(default): support DropdownMenuItem onSelect (#4787)
fix(ui): add onSelect compat wrapper for DropdownMenuItem

Bridges Base UI DropdownMenu with Radix-style onSelect so existing consumers work without migration.
2026-05-12 16:23:24 +08:00
Micah-Zheng 7fe896d2f8 fix: use getUserGroups for ratio display to respect GroupGroupRatio (#4772)
fix(web/default): use getUserGroups for ratio display to respect GroupGroupRatio

Unifies admin/user ratio display so API key list matches the actual billing ratio.
2026-05-12 16:23:14 +08:00
ying2 3057f04a17 fix(wallet): read topup gateway flags from topupInfo instead of status (#4599)
fix(wallet): read topup gateway flags from topupInfo instead of status

Fixes #4632 — subscription purchase modal wrongly said online payment not enabled.
2026-05-12 16:23:04 +08:00
CaIon 03d537328a fix(default): improve performance health panel layout 2026-05-12 16:13:14 +08:00
CaIon 19fc384e67 feat(performance): update performance metrics handling and UI components 2026-05-12 16:04:15 +08:00
CaIon ba474393fb fix(default): resolve v1 frontend issue regressions
Fix v1 frontend regressions across channel forms, dashboard charts, wallet history, payment callbacks, invite links, API key groups, rate-limit errors, and usage-log scrolling.

Fixes #4715
Fixes #4618
Fixes #4699
Fixes #4651
Fixes #4637
Fixes #4682
Fixes #4691
Fixes #4565
Fixes #4334
2026-05-11 11:25:25 +08:00
夜影星辰 5fa103fa5b fix: exclude THIRD-PARTY-LICENSES.md from .dockerignore for Docker build (#4739) 2026-05-11 10:47:58 +08:00
CaIon 543cc64ea3 feat(licenses): add LICENSE, NOTICE, and THIRD-PARTY-LICENSES files to Docker images 2026-05-09 21:39:30 +08:00
t0ng7u d146e45e2f ⚖️ chore(web/default): add reusable copyright header tooling
Add a Bun script to apply and normalize AGPL copyright headers across the default frontend source files.

The script keeps headers idempotent, upgrades existing headers to the 2023-2026 QuantumNous range, and is exposed through `bun run copyright` for future maintenance.
2026-05-09 11:35:07 +08:00
yyhhyyyyyy 560ba57c88 feat: add DeepChat deeplink support (#4668) 2026-05-08 18:13:20 +08:00
t0ng7u 948780e3fa 🎨 fix(theme): align UI controls with global radius tokens
Remove hard-coded and capped border radius overrides so shared controls and feature actions consistently follow the active theme radius.

- Replace fixed radius utilities with semantic theme-aware radius tokens
- Remove redundant `rounded-full` and pixel-based overrides from header, toolbar, Playground, and utility actions
- Drop unused `StatusBadge` rounded prop usage
- Keep existing component behavior intact while improving global theme consistency
2026-05-08 01:50:03 +08:00
t0ng7u c19d5aa663 feat: Add model performance metrics to dashboard
Add a shared `performance-metrics` feature module for perf metric APIs, DTOs, and formatting, then surface global 24h model performance on the dashboard with cards and a top-model table.

Reuse the shared metrics module from pricing model details, remove duplicated perf API/formatting code from pricing, and add localized labels for the new dashboard performance UI.
2026-05-08 01:06:44 +08:00
SAY-5 faa0f1425a fix: qualify column names in PerfMetric upsert to avoid ambiguity
PostgreSQL raises 'column reference is ambiguous' (SQLSTATE 42702) on
ON CONFLICT DO UPDATE because unqualified column names match both the
target row and EXCLUDED. Prefix with the table name so the existing
value is referenced unambiguously. Compatible with MySQL and SQLite.

Closes #4683

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>
2026-05-07 05:58:57 -07:00
t0ng7u a7475a1e67 🎨 fix(web): align UI and charts with theme tokens and presets
Improve theme switching fidelity (including system preference), extend design tokens so color presets tint real surfaces—not only primary/chrome—and refactor shared badges, tables, and dashboard visuals to semantic colors. Wire VChart series colors to `--chart-*` with safe fallbacks.

**Changes**

- **Theme runtime** (`theme-provider.tsx`): Validate stored theme cookie; keep `resolvedTheme` in sync with DOM + `(prefers-color-scheme)`; `resetTheme` respects `defaultTheme`; memoized context value.
- **Tokens** (`theme.css`): Add `--success|warning|info|neutral` (+ foregrounds) and map them under `@theme inline` for Tailwind utilities.
- **Presets** (`theme-presets.css`): For non-`default` presets, derive `card`, `popover`, `muted`, `accent`, `border`, `input`, and sidebar tokens from `--primary`/`--background`; map semantic status colors to preset chart variables.
- **Components**: `status-badge`, `colors` (avatars, announcements), `copy-button`, `group-badge`, `data-table` row styles, `sidebar` outline shadow (fix `var(--sidebar-border)` usage), ai-elements tool/web-preview status colors.
- **Dashboard**: Latency/API helpers and overview fragments use semantic tokens; `charts.ts` reads `--chart-1`…`--chart-5` from computed styles with fallbacks; `processChartData` / `processUserChartData` accept optional `themeKey` for preset churn; chart components pass `customization.preset` and bump `VChart` keys.

**Verification**

- `bun run typecheck`
2026-05-07 11:20:43 +08:00
t0ng7u 415d21d071 ♻️ refactor(layout): rename workspace switcher to system brand
Rename the layout branding component to reflect that it displays the system identity rather than switching workspaces. Update header usage and layout exports, and remove the now-unused workspace data dependency.
2026-05-07 03:54:32 +08:00
t0ng7u abc255dd6d ☀ fix(default): keep SectionPageLayout description slot hidden
Keep SectionPageLayout.Description as a non-rendering composition slot so callers stay compatible while page subtitles remain hidden across the app.
2026-05-07 03:26:22 +08:00
t0ng7u a7d019e3a9 feat(default): redesign dashboard overview
Refresh the overview page with an actionable Get Started guide, live API request details, real usage sparklines, and OpenAI-inspired dashboard panels. Add collapsible setup state, role-aware quick actions, and localized copy so returning users can focus on platform health.
2026-05-07 03:20:35 +08:00
1007 changed files with 23818 additions and 2080 deletions
+6 -1
View File
@@ -7,4 +7,9 @@ Makefile
docs
.eslintcache
.gocache
/web/node_modules
/web/node_modules
/web/default/node_modules
/web/default/dist
/web/classic/node_modules
/web/classic/dist
!THIRD-PARTY-LICENSES.md
+1 -1
View File
@@ -9,4 +9,4 @@ community_bridge: # Replace with a single Community Bridge project-name e.g., cl
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: ['https://afdian.com/a/new-api'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+1
View File
@@ -44,6 +44,7 @@ RUN apt-get update \
&& update-ca-certificates
COPY --from=builder2 /build/new-api /
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/new-api"]
+1
View File
@@ -30,6 +30,7 @@ RUN apt-get update \
&& update-ca-certificates
COPY --from=builder /build/new-api /
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/new-api"]
+58
View File
@@ -0,0 +1,58 @@
new-api Notices
new-api
Copyright (c) QuantumNous and contributors.
This project is licensed under the GNU Affero General Public License v3.0.
See LICENSE for the full project license terms.
==== Additional Terms under AGPLv3 Section 7 ====
Pursuant to Section 7(b) of the GNU Affero General Public License version 3,
the following reasonable legal notice and author attribution must be preserved
by modified versions in the Appropriate Legal Notices and in any prominent
about, legal, footer, or attribution location presented by the user interface:
"Frontend design and development by New API contributors."
Modified versions that present a user interface must also preserve a visible
link to the original project in a prominent about, legal, footer, or
attribution location:
https://github.com/QuantumNous/new-api
Modified versions must not misrepresent the origin of the software and must
mark their changes in accordance with AGPLv3 Section 7(c).
==== Third-Party Notices ====
This product includes third-party open source software. Copyright notices and
license terms for direct third-party dependencies are listed in
THIRD-PARTY-LICENSES.md.
Apache-2.0 upstream NOTICE entries identified for direct dependencies are
reproduced below. Preserve this file with Docker images, standalone binaries,
frontend bundles, and Electron desktop installers distributed to users.
==== Apache-2.0 Notices ====
AWS SDK for Go
Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved.
Copyright 2014-2015 Stripe, Inc.
smithy-go
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
otp
Copyright (c) 2014, Paul Querna
This product includes software developed by
Paul Querna (http://paul.querna.org/).
==== Electron / Chromium Notices ====
Desktop distributions include Electron, which embeds Chromium, Node.js, V8,
and other third-party components. Electron and Chromium third-party license
notices must remain available with desktop installers and installed apps.
==== End of Notices ====
+15 -11
View File
@@ -53,9 +53,10 @@
> This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api)
> [!IMPORTANT]
> - This project is for personal learning purposes only, with no guarantee of stability or technical support
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes
> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China.
> - This project is intended solely for lawful and authorized AI API gateway, organization-level authentication, multi-model management, usage analytics, cost accounting, and private deployment scenarios.
> - Users must lawfully obtain upstream API keys, accounts, model services, and interface permissions, and must comply with upstream terms of service and applicable laws and regulations.
> - Users should ensure their use complies with upstream terms of service and applicable laws and regulations.
> - When providing generative AI services to the public, users should comply with applicable regulatory requirements and fulfill all filing, licensing, content safety, real-name verification, log retention, tax, and upstream authorization obligations required by their jurisdiction.
---
@@ -146,6 +147,9 @@ docker run --name new-api -d --restart always \
🎉 After deployment is complete, visit `http://localhost:3000` to start using!
> [!WARNING]
> When operating this project as a public generative AI service or API resale service, users should first complete all required filing, licensing, content safety, real-name verification, log retention, tax, payment, and upstream authorization obligations.
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation)
---
@@ -184,12 +188,12 @@ docker run --name new-api -d --restart always \
| 📈 Data Dashboard | Visual console and statistical analysis |
| 🔒 Permission Management | Token grouping, model restrictions, user management |
### 💰 Payment and Billing
### 💰 Authorized Usage Accounting and Billing
-Online recharge (EPay, Stripe)
-Pay-per-use model pricing
- ✅ Cache billing support (OpenAI, Azure, DeepSeek, Claude, Qwen and all supported models)
- ✅ Flexible billing policy configuration
-Internal top-up and quota allocation for lawful authorized scenarios (EPay, Stripe)
-Organization-level per-request, usage-based, and cache-hit cost accounting
- ✅ Cache billing statistics for OpenAI, Azure, DeepSeek, Claude, Qwen, and supported models
- ✅ Flexible billing policies for internal management or authorized enterprise customers
### 🔐 Authorization and Security
@@ -248,7 +252,7 @@ docker run --name new-api -d --restart always \
## 🤖 Model Support
> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/en/docs/api)
> For details, please refer to [API Documentation - Gateway Interface](https://docs.newapi.pro/en/docs/api)
| Model Type | Description | Documentation |
|---------|------|------|
@@ -259,7 +263,7 @@ docker run --name new-api -d --restart always \
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) |
| 🌐 Gemini | Google Gemini format | [Documentation](https://doc.newapi.pro/en/api/google-gemini-chat) |
| 🔧 Dify | ChatFlow mode | - |
| 🎯 Custom | Supports complete call address | - |
| 🎯 Custom upstream | Supports configuring legally authorized upstream endpoints | - |
### 📡 Supported Interfaces
@@ -409,7 +413,7 @@ docker run --name new-api -d --restart always \
| Project | Description |
|------|------|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key quota query tool |
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Key quota query tool |
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API high-performance optimized version |
---
+16 -12
View File
@@ -55,9 +55,10 @@
## 📝 Description du projet
> [!IMPORTANT]
> - Ce projet est uniquement destiné à des fins d'apprentissage personnel, sans garantie de stabilité ni de support technique.
> - Les utilisateurs doivent se conformer aux [Conditions d'utilisation](https://openai.com/policies/terms-of-use) d'OpenAI et aux **lois et réglementations applicables**, et ne doivent pas l'utiliser à des fins illégales.
> - Conformément aux [《Mesures provisoires pour la gestion des services d'intelligence artificielle générative》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), veuillez ne fournir aucun service d'IA générative non enregistré au public en Chine.
> - Ce projet est exclusivement destiné aux scénarios de passerelle API d'IA légalement autorisés, d'authentification organisationnelle, de gestion multi-modèles, d'analyse d'utilisation, de comptabilisation des coûts et de déploiement privé.
> - Les utilisateurs doivent obtenir légalement les clés API, comptes, services de modèles et autorisations d'interface en amont, et doivent respecter les conditions d'utilisation en amont et les lois et réglementations applicables.
> - Les utilisateurs doivent s'assurer que leur utilisation est conforme aux conditions d'utilisation en amont et aux lois et réglementations applicables.
> - Lors de la fourniture de services d'IA générative au public, les utilisateurs doivent se conformer aux exigences réglementaires applicables et remplir toutes les obligations d'enregistrement, de licence, de sécurité du contenu, de vérification d'identité, de conservation des journaux, de fiscalité et d'autorisation en amont requises par leur juridiction.
---
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
🎉 Après le déploiement, visitez `http://localhost:3000` pour commencer à utiliser!
> [!WARNING]
> Lorsque vous exploitez ce projet en tant que service public d'IA générative ou service de revente d'API, les utilisateurs doivent d'abord remplir toutes les obligations requises en matière d'enregistrement, de licence, de sécurité du contenu, de vérification d'identité, de conservation des journaux, de fiscalité, de paiement et d'autorisation en amont.
📖 Pour plus de méthodes de déploiement, veuillez vous référer à [Guide de déploiement](https://docs.newapi.pro/en/docs/installation)
---
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
| 📈 Tableau de bord des données | Console visuelle et analyse statistique |
| 🔒 Gestion des permissions | Regroupement de jetons, restrictions de modèles, gestion des utilisateurs |
### 💰 Paiement et facturation
### 💰 Comptabilisation et facturation des usages autorisés
- ✅ Recharge en ligne (EPay, Stripe)
-Tarification des modèles de paiement à l'utilisation
-Prise en charge de la facturation du cache (OpenAI, Azure, DeepSeek, Claude, Qwen et tous les modèles pris en charge)
-Configuration flexible des politiques de facturation
- ✅ Rechargement interne et allocation de quotas pour les scénarios légalement autorisés (EPay, Stripe)
-Comptabilisation des coûts par requête, par utilisation et par hit de cache au niveau organisationnel
-Statistiques de facturation du cache pour OpenAI, Azure, DeepSeek, Claude, Qwen et les modèles pris en charge
-Politiques de facturation flexibles pour la gestion interne ou les clients entreprise autorisés
### 🔐 Autorisation et sécurité
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
- 🤖 Connexion par autorisation LinuxDO
- 📱 Connexion par autorisation Telegram
- 🔑 Authentification unifiée OIDC
- 🔍 Requête de quota d'utilisation de clé (avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
- 🔍 Requête de quota d'utilisation de clé (avec [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
### 🚀 Fonctionnalités avancées
@@ -254,7 +258,7 @@ docker run --name new-api -d --restart always \
## 🤖 Prise en charge des modèles
> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de relais](https://docs.newapi.pro/en/docs/api)
> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de passerelle](https://docs.newapi.pro/en/docs/api)
| Type de modèle | Description | Documentation |
|---------|------|------|
@@ -266,7 +270,7 @@ docker run --name new-api -d --restart always \
| 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/createmessage) |
| 🌐 Gemini | Format Google Gemini | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
| 🔧 Dify | Mode ChatFlow | - |
| 🎯 Personnalisé | Prise en charge de l'adresse d'appel complète | - |
| 🎯 Amont personnalisé | Configuration des points d'accès amont légalement autorisés | - |
### 📡 Interfaces prises en charge
@@ -416,7 +420,7 @@ docker run --name new-api -d --restart always \
| Projet | Description |
|------|------|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Outil de recherche de quota d'utilisation avec une clé |
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Outil de recherche de quota d'utilisation avec une clé |
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | Version optimisée haute performance de New API |
---
+16 -12
View File
@@ -55,9 +55,10 @@
## 📝 プロジェクト説明
> [!IMPORTANT]
> - 本プロジェクトは個人学習用のみであり、安定性の保証や技術サポートは提供しません
> - ユーザーは、OpenAIの[利用規約](https://openai.com/policies/terms-of-use)および**法律法規**を遵守する必要があり、違法な目的で使用してはいけません
> - [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)の要求に従い、中国地域の公衆に未登録の生成式AI サービスを提供しないでください。
> - 本プロジェクトは、合法的に許可された AI API ゲートウェイ、組織レベルの認証、マルチモデル管理、利用量分析、コスト管理、プライベートデプロイのシナリオのみを対象としています
> - ユーザーは、上流の API キー、アカウント、モデルサービス、インターフェース権限を合法的に取得し、上流のサービス利用規約および適用される法律法規を遵守する必要があります
> - ユーザーは、利用方法が上流のサービス利用規約および適用される法律法規に準拠していることを確認してください。
> - 生成 AI サービスを公衆に提供する場合、ユーザーは適用される規制要件を遵守し、管轄区域で求められる届出、ライセンス、コンテンツセキュリティ、本人確認、ログ保持、税務、上流認可などのすべての義務を履行してください。
---
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
🎉 デプロイが完了したら、`http://localhost:3000` にアクセスして使用を開始してください!
> [!WARNING]
> 本プロジェクトを公衆向け生成 AI サービスまたは API 再販サービスとして運営する場合、ユーザーは届出、コンテンツセキュリティ、本人確認、ログ保持、税務、決済、上流認可などの必要なコンプライアンス義務を先に完了してください。
📖 その他のデプロイ方法については[デプロイガイド](https://docs.newapi.pro/ja/docs/installation)を参照してください。
---
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
| 📈 データダッシュボード | ビジュアルコンソールと統計分析 |
| 🔒 権限管理 | トークングループ化、モデル制限、ユーザー管理 |
### 💰 支払いと課金
### 💰 認可済み利用量とコスト管理
-オンライン充電EPay、Stripe
-モデルの従量課金
-キャッシュ課金サポート(OpenAI、Azure、DeepSeek、Claude、Qwenなどすべてのサポートされているモデル)
- ✅ 柔軟な課金ポリシー設定
-合法的に許可されたシナリオでの内部チャージとクォータ割り当てEPay、Stripe
-組織レベルのリクエスト単位、使用量ベース、キャッシュヒットのコスト会計
- ✅ OpenAI、Azure、DeepSeek、Claude、Qwen などのモデルのキャッシュ課金統計
-内部管理または認可済み企業顧客向けの柔軟な課金ポリシー
### 🔐 認証とセキュリティ
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
- 🤖 LinuxDO認証ログイン
- 📱 Telegram認証ログイン
- 🔑 OIDC統一認証
- 🔍 Key使用量クォータ照会([neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)と併用)
- 🔍 Key使用量クォータ照会([new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool)と併用)
@@ -256,7 +260,7 @@ docker run --name new-api -d --restart always \
## 🤖 モデルサポート
> 詳細については[APIドキュメント - 中継インターフェース](https://docs.newapi.pro/ja/docs/api)
> 詳細については[APIドキュメント - ゲートウェイインターフェース](https://docs.newapi.pro/ja/docs/api)
| モデルタイプ | 説明 | ドキュメント |
|---------|------|------|
@@ -268,7 +272,7 @@ docker run --name new-api -d --restart always \
| 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/createmessage) |
| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
| 🔧 Dify | ChatFlowモード | - |
| 🎯 カスタム | 完全な呼び出しアドレスの入力をサポート | - |
| 🎯 カスタム上流 | 合法的に許可された上流エンドポイントの設定をサポート | - |
### 📡 サポートされているインターフェース
@@ -416,7 +420,7 @@ docker run --name new-api -d --restart always \
| プロジェクト | 説明 |
|------|------|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | キー使用量クォータ照会ツール |
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | キー使用量クォータ照会ツール |
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API高性能最適化版 |
---
+24 -12
View File
@@ -55,9 +55,10 @@
## 📝 Project Description
> [!IMPORTANT]
> - This project is for personal learning purposes only, with no guarantee of stability or technical support
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes
> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China.
> - This project is intended solely for lawful and authorized AI API gateway, organization-level authentication, multi-model management, usage analytics, cost accounting, and private deployment scenarios.
> - Users must lawfully obtain upstream API keys, accounts, model services, and interface permissions, and must comply with upstream terms of service and applicable laws and regulations.
> - Users should ensure their use complies with upstream terms of service and applicable laws and regulations.
> - When providing generative AI services to the public, users should comply with applicable regulatory requirements and fulfill all filing, licensing, content safety, real-name verification, log retention, tax, and upstream authorization obligations required by their jurisdiction.
---
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
🎉 After deployment is complete, visit `http://localhost:3000` to start using!
> [!WARNING]
> When operating this project as a public generative AI service or API resale service, users should first complete all required filing, licensing, content safety, real-name verification, log retention, tax, payment, and upstream authorization obligations.
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation)
---
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
| 📈 Data Dashboard | Visual console and statistical analysis |
| 🔒 Permission Management | Token grouping, model restrictions, user management |
### 💰 Payment and Billing
### 💰 Authorized Usage Accounting and Billing
-Online recharge (EPay, Stripe)
-Pay-per-use model pricing
- ✅ Cache billing support (OpenAI, Azure, DeepSeek, Claude, Qwen and all supported models)
- ✅ Flexible billing policy configuration
-Internal top-up and quota allocation for lawful authorized scenarios (EPay, Stripe)
-Organization-level per-request, usage-based, and cache-hit cost accounting
- ✅ Cache billing statistics for OpenAI, Azure, DeepSeek, Claude, Qwen, and supported models
- ✅ Flexible billing policies for internal management or authorized enterprise customers
### 🔐 Authorization and Security
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
- 🤖 LinuxDO authorization login
- 📱 Telegram authorization login
- 🔑 OIDC unified authentication
- 🔍 Key quota query usage (with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
- 🔍 Key quota query usage (with [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
### 🚀 Advanced Features
@@ -254,7 +258,7 @@ docker run --name new-api -d --restart always \
## 🤖 Model Support
> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/en/docs/api)
> For details, please refer to [API Documentation - Gateway Interface](https://docs.newapi.pro/en/docs/api)
| Model Type | Description | Documentation |
|---------|------|------|
@@ -266,7 +270,7 @@ docker run --name new-api -d --restart always \
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/createmessage) |
| 🌐 Gemini | Google Gemini format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
| 🔧 Dify | ChatFlow mode | - |
| 🎯 Custom | Supports complete call address | - |
| 🎯 Custom upstream | Supports configuring legally authorized upstream endpoints | - |
### 📡 Supported Interfaces
@@ -416,7 +420,7 @@ docker run --name new-api -d --restart always \
| Project | Description |
|------|------|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key quota query tool |
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Key quota query tool |
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API high-performance optimized version |
---
@@ -447,6 +451,14 @@ Welcome all forms of contribution!
This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
Additional terms under AGPLv3 Section 7 apply. Modified versions must preserve
the author attribution notice `Frontend design and development by New API
contributors.` in the appropriate legal notices and in any prominent about,
legal, footer, or attribution location presented by the user interface.
Modified versions that present a user interface must also preserve a visible
link to the original project: <https://github.com/QuantumNous/new-api>.
This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api) (MIT License).
If your organization's policies do not permit the use of AGPLv3-licensed software, or if you wish to avoid the open-source obligations of AGPLv3, please contact us at: [support@quantumnous.com](mailto:support@quantumnous.com)
+16 -12
View File
@@ -55,9 +55,10 @@
## 📝 项目说明
> [!IMPORTANT]
> - 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持
> - 使用者必须在遵循 OpenAI 的 [使用条款](https://openai.com/policies/terms-of-use) 以及**法律法规**的情况下使用,不得用于非法用途
> - 根据 [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm) 的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务
> - 本项目仅面向合法授权的 AI API 网关、组织内部鉴权、多模型管理、用量统计、成本核算和私有化部署场景。
> - 使用者必须合法取得上游 API Key、账号、模型服务或接口权限,并遵守上游服务条款及适用法律法规。
> - 使用者应确保其使用方式符合上游服务条款及适用法律法规。
> - 面向公众提供生成式人工智能服务时,使用者应遵守[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)等监管要求,自行完成所在司法辖区要求的备案、许可、内容安全、实名、日志留存、税务和上游授权等合规义务。
---
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
🎉 部署完成后,访问 `http://localhost:3000` 即可使用!
> [!WARNING]
> 将本项目作为面向公众的生成式 AI 服务或 API 转售服务运营时,使用者应先完成备案、内容安全、实名、日志留存、税务、支付和上游授权等合规义务。
📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/zh/docs/installation)
---
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
| 📈 数据看板 | 可视化控制台与统计分析 |
| 🔒 权限管理 | 令牌分组、模型限制、用户管理 |
### 💰 支付与计费
### 💰 授权用量与成本管理
-在线充值(易支付、Stripe
-模型按次数收费
-缓存计费支持OpenAI、Azure、DeepSeek、Claude、Qwen等所有支持的模型)
- ✅ 灵活计费策略配置
-合法授权场景下的内部充值与额度分配(易支付、Stripe
-组织内按次、按量或缓存命中成本核算
- ✅ 支持 OpenAI、Azure、DeepSeek、Claude、Qwen 等模型的缓存计费统计
-面向内部管理或企业客户的灵活计费策略配置
### 🔐 授权与安全
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
- 🤖 LinuxDO 授权登录
- 📱 Telegram 授权登录
- 🔑 OIDC 统一认证
- 🔍 Key 查询使用额度(配合 [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)
- 🔍 Key 查询使用额度(配合 [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool)
### 🚀 高级功能
@@ -254,7 +258,7 @@ docker run --name new-api -d --restart always \
## 🤖 模型支持
> 详情请参考 [接口文档 - 中继接口](https://docs.newapi.pro/zh/docs/api)
> 详情请参考 [接口文档 - 网关接口](https://docs.newapi.pro/zh/docs/api)
| 模型类型 | 说明 | 文档 |
|---------|------|------|
@@ -266,7 +270,7 @@ docker run --name new-api -d --restart always \
| 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/createmessage) |
| 🌐 Gemini | Google Gemini 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
| 🔧 Dify | ChatFlow 模式 | - |
| 🎯 自定义 | 支持完整调用地址 | - |
| 🎯 自定义上游 | 支持配置合法授权的上游接口地址 | - |
### 📡 支持的接口
@@ -416,7 +420,7 @@ docker run --name new-api -d --restart always \
| 项目 | 说明 |
|------|------|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key 额度查询工具 |
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Key 额度查询工具 |
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API 高性能优化版 |
---
+16 -12
View File
@@ -55,9 +55,10 @@
## 📝 項目說明
> [!IMPORTANT]
> - 本項目僅供個人學習使用,不保證穩定性,且不提供任何技術支援
> - 使用者必須在遵循 OpenAI 的 [使用條款](https://openai.com/policies/terms-of-use) 以及**法律法規**的情況下使用,不得用於非法用途
> - 根據 [《生成式人工智慧服務管理暫行辦法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm) 的要求,請勿對中國地區公眾提供一切未經備案的生成式人工智慧服務
> - 本專案僅面向合法授權的 AI API 閘道、組織內部鑑權、多模型管理、用量統計、成本核算和私有化部署場景。
> - 使用者必須合法取得上游 API Key、帳號、模型服務或介面權限,並遵守上游服務條款及適用法律法規。
> - 使用者應確保其使用方式符合上游服務條款及適用法律法規。
> - 面向公眾提供生成式人工智慧服務時,使用者應遵守[《生成式人工智慧服務管理暫行辦法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)等監管要求,自行完成所在司法轄區要求的備案、許可、內容安全、實名、日誌留存、稅務和上游授權等合規義務。
---
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
🎉 部署完成後,訪問 `http://localhost:3000` 即可使用!
> [!WARNING]
> 將本專案作為面向公眾的生成式 AI 服務或 API 轉售服務運營時,使用者應先完成備案、內容安全、實名、日誌留存、稅務、支付和上游授權等合規義務。
📖 更多部署方式請參考 [部署指南](https://docs.newapi.pro/zh/docs/installation)
---
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
| 📈 數據看板 | 視覺化控制檯與統計分析 |
| 🔒 權限管理 | 令牌分組、模型限制、用戶管理 |
### 💰 支付與計費
### 💰 授權用量與成本管理
-在線儲值(易支付、Stripe
-模型按次數收費
-快取計費支援OpenAI、Azure、DeepSeek、Claude、Qwen等所有支援的模型)
- ✅ 靈活計費策略配置
-合法授權場景下的內部儲值與額度分配(易支付、Stripe
-組織內按次、按量或快取命中成本核算
- ✅ 支援 OpenAI、Azure、DeepSeek、Claude、Qwen 等模型的快取計費統計
-面向內部管理或企業客戶的靈活計費策略配置
### 🔐 授權與安全
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
- 🤖 LinuxDO 授權登錄
- 📱 Telegram 授權登錄
- 🔑 OIDC 統一認證
- 🔍 Key 查詢使用額度(配合 [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)
- 🔍 Key 查詢使用額度(配合 [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool)
### 🚀 高級功能
@@ -254,7 +258,7 @@ docker run --name new-api -d --restart always \
## 🤖 模型支援
> 詳情請參考 [接口文件 - 中繼接口](https://docs.newapi.pro/zh/docs/api)
> 詳情請參考 [接口文件 - 閘道接口](https://docs.newapi.pro/zh/docs/api)
| 模型類型 | 說明 | 文件 |
|---------|------|------|
@@ -266,7 +270,7 @@ docker run --name new-api -d --restart always \
| 💬 Claude | Messages 格式 | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/chat/createmessage) |
| 🌐 Gemini | Google Gemini 格式 | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
| 🔧 Dify | ChatFlow 模式 | - |
| 🎯 自訂 | 支援完整調用位址 | - |
| 🎯 自訂上游 | 支援配置合法授權的上游介面位址 | - |
### 📡 支援的接口
@@ -416,7 +420,7 @@ docker run --name new-api -d --restart always \
| 項目 | 說明 |
|------|------|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key 額度查詢工具 |
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Key 額度查詢工具 |
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API 高性能優化版 |
---
+375
View File
@@ -0,0 +1,375 @@
# Third-Party Licenses
This file summarizes direct third-party dependencies used by distributed builds of this project.
It is an engineering compliance artifact and should be kept with Docker images, standalone binaries, frontend bundles, and Electron installers.
Scope: direct dependencies from `go.mod`, `web/default/package.json`, `web/classic/package.json`, and `electron/package.json`.
Transitive dependencies should be audited before a final external release.
## Dependency Inventory
| Area | Scope | Ecosystem | Dependency | Version | License |
|-------------|-------------|-----------|-------------------------------------------------------|--------------------------------------|----------------------------------------------------|
| backend | production | Go | `github.com/Calcium-Ion/go-epay` | `v0.0.4` | Proprietary/Internal - owned by project maintainer |
| backend | production | Go | `github.com/abema/go-mp4` | `v1.4.1` | MIT |
| backend | production | Go | `github.com/andybalholm/brotli` | `v1.1.1` | MIT |
| backend | production | Go | `github.com/anknown/ahocorasick` | `v0.0.0-20190904063843-d75dbd5169c0` | MIT |
| backend | production | Go | `github.com/aws/aws-sdk-go-v2` | `v1.41.5` | Apache-2.0 |
| backend | production | Go | `github.com/aws/aws-sdk-go-v2/credentials` | `v1.19.10` | Apache-2.0 |
| backend | production | Go | `github.com/aws/aws-sdk-go-v2/service/bedrockruntime` | `v1.50.4` | Apache-2.0 |
| backend | production | Go | `github.com/aws/smithy-go` | `v1.24.2` | Apache-2.0 |
| backend | production | Go | `github.com/bytedance/gopkg` | `v0.1.3` | Apache-2.0 |
| backend | production | Go | `github.com/gin-contrib/cors` | `v1.7.2` | MIT |
| backend | production | Go | `github.com/gin-contrib/gzip` | `v0.0.6` | MIT |
| backend | production | Go | `github.com/gin-contrib/sessions` | `v0.0.5` | MIT |
| backend | production | Go | `github.com/gin-contrib/static` | `v0.0.1` | MIT |
| backend | production | Go | `github.com/gin-gonic/gin` | `v1.9.1` | MIT |
| backend | production | Go | `github.com/glebarez/sqlite` | `v1.9.0` | MIT |
| backend | production | Go | `github.com/go-audio/aiff` | `v1.1.0` | Apache-2.0 |
| backend | production | Go | `github.com/go-audio/wav` | `v1.1.0` | Apache-2.0 |
| backend | production | Go | `github.com/go-playground/validator/v10` | `v10.20.0` | MIT |
| backend | production | Go | `github.com/go-redis/redis/v8` | `v8.11.5` | BSD-2-Clause |
| backend | production | Go | `github.com/go-webauthn/webauthn` | `v0.14.0` | BSD-3-Clause |
| backend | production | Go | `github.com/golang-jwt/jwt/v5` | `v5.3.0` | MIT |
| backend | production | Go | `github.com/google/uuid` | `v1.6.0` | BSD-3-Clause |
| backend | production | Go | `github.com/gorilla/websocket` | `v1.5.0` | BSD-2-Clause |
| backend | production | Go | `github.com/grafana/pyroscope-go` | `v1.2.7` | Apache-2.0 |
| backend | production | Go | `github.com/jfreymuth/oggvorbis` | `v1.0.5` | MIT |
| backend | production | Go | `github.com/jinzhu/copier` | `v0.4.0` | MIT |
| backend | production | Go | `github.com/joho/godotenv` | `v1.5.1` | MIT |
| backend | production | Go | `github.com/mewkiz/flac` | `v1.0.13` | Unlicense |
| backend | production | Go | `github.com/nicksnyder/go-i18n/v2` | `v2.6.1` | MIT |
| backend | production | Go | `github.com/pkg/errors` | `v0.9.1` | BSD-2-Clause |
| backend | production | Go | `github.com/pquerna/otp` | `v1.5.0` | Apache-2.0 |
| backend | production | Go | `github.com/samber/hot` | `v0.11.0` | MIT |
| backend | production | Go | `github.com/samber/lo` | `v1.52.0` | MIT |
| backend | production | Go | `github.com/shirou/gopsutil` | `v3.21.11+incompatible` | BSD-3-Clause |
| backend | production | Go | `github.com/shopspring/decimal` | `v1.4.0` | MIT |
| backend | production | Go | `github.com/stretchr/testify` | `v1.11.1` | MIT |
| backend | production | Go | `github.com/stripe/stripe-go/v81` | `v81.4.0` | MIT |
| backend | production | Go | `github.com/tcolgate/mp3` | `v0.0.0-20170426193717-e79c5a46d300` | MIT |
| backend | production | Go | `github.com/thanhpk/randstr` | `v1.0.6` | MIT |
| backend | production | Go | `github.com/tidwall/gjson` | `v1.18.0` | MIT |
| backend | production | Go | `github.com/tidwall/sjson` | `v1.2.5` | MIT |
| backend | production | Go | `github.com/tiktoken-go/tokenizer` | `v0.6.2` | MIT |
| backend | production | Go | `github.com/waffo-com/waffo-go` | `v1.3.1` | MIT |
| backend | production | Go | `github.com/yapingcat/gomedia` | `v0.0.0-20240906162731-17feea57090c` | MIT |
| backend | production | Go | `golang.org/x/crypto` | `v0.45.0` | BSD-3-Clause |
| backend | production | Go | `golang.org/x/image` | `v0.38.0` | BSD-3-Clause |
| backend | production | Go | `golang.org/x/net` | `v0.47.0` | BSD-3-Clause |
| backend | production | Go | `golang.org/x/sync` | `v0.20.0` | BSD-3-Clause |
| backend | production | Go | `golang.org/x/sys` | `v0.38.0` | BSD-3-Clause |
| backend | production | Go | `golang.org/x/text` | `v0.35.0` | BSD-3-Clause |
| backend | production | Go | `gopkg.in/yaml.v3` | `v3.0.1` | Apache-2.0 OR MIT |
| backend | production | Go | `gorm.io/driver/mysql` | `v1.4.3` | MIT |
| backend | production | Go | `gorm.io/driver/postgres` | `v1.5.2` | MIT |
| backend | production | Go | `gorm.io/gorm` | `v1.25.2` | MIT |
| backend | production | Go | `github.com/expr-lang/expr` | `v1.17.8` | MIT |
| web/default | production | npm | `@base-ui/react` | `1.4.1` | MIT |
| web/default | production | npm | `@fontsource-variable/public-sans` | `5.2.7` | OFL-1.1 |
| web/default | production | npm | `@hookform/resolvers` | `5.2.2` | MIT |
| web/default | production | npm | `@hugeicons/core-free-icons` | `4.1.1` | MIT |
| web/default | production | npm | `@hugeicons/react` | `1.1.6` | MIT |
| web/default | production | npm | `@lobehub/icons` | `4.12.0` | MIT |
| web/default | production | npm | `@tailwindcss/postcss` | `4.2.2` | MIT |
| web/default | production | npm | `@tanstack/react-query` | `5.97.0` | MIT |
| web/default | production | npm | `@tanstack/react-router` | `1.168.23` | MIT |
| web/default | production | npm | `@tanstack/react-table` | `8.21.3` | MIT |
| web/default | production | npm | `@tanstack/react-virtual` | `3.13.23` | MIT |
| web/default | production | npm | `@visactor/react-vchart` | `2.0.21` | MIT |
| web/default | production | npm | `@visactor/vchart` | `2.0.21` | MIT |
| web/default | production | npm | `ai` | `6.0.158` | Apache-2.0 |
| web/default | production | npm | `auto-skeleton-react` | `1.0.5` | MIT |
| web/default | production | npm | `axios` | `1.15.0` | MIT |
| web/default | production | npm | `class-variance-authority` | `0.7.1` | Apache-2.0 |
| web/default | production | npm | `clsx` | `2.1.1` | MIT |
| web/default | production | npm | `cmdk` | `1.1.1` | MIT |
| web/default | production | npm | `date-fns` | `4.1.0` | MIT |
| web/default | production | npm | `dayjs` | `1.11.20` | MIT |
| web/default | production | npm | `i18next` | `25.10.10` | MIT |
| web/default | production | npm | `i18next-browser-languagedetector` | `8.2.1` | MIT |
| web/default | production | npm | `input-otp` | `1.4.2` | MIT |
| web/default | production | npm | `lucide-react` | `1.8.0` | ISC |
| web/default | production | npm | `motion` | `12.38.0` | MIT |
| web/default | production | npm | `nanoid` | `5.1.7` | MIT |
| web/default | production | npm | `next-themes` | `0.4.6` | MIT |
| web/default | production | npm | `qrcode.react` | `4.2.0` | ISC |
| web/default | production | npm | `react` | `19.2.5` | MIT |
| web/default | production | npm | `react-day-picker` | `9.14.0` | MIT |
| web/default | production | npm | `react-dom` | `19.2.5` | MIT |
| web/default | production | npm | `react-hook-form` | `7.72.1` | MIT |
| web/default | production | npm | `react-i18next` | `16.6.6` | MIT |
| web/default | production | npm | `react-icons` | `5.6.0` | MIT |
| web/default | production | npm | `react-markdown` | `10.1.0` | MIT |
| web/default | production | npm | `react-resizable-panels` | `4.11.0` | MIT |
| web/default | production | npm | `react-top-loading-bar` | `3.0.2` | MIT |
| web/default | production | npm | `recharts` | `3.8.0` | MIT |
| web/default | production | npm | `rehype-raw` | `7.0.0` | MIT |
| web/default | production | npm | `remark-gfm` | `4.0.1` | MIT |
| web/default | production | npm | `shiki` | `4.0.2` | MIT |
| web/default | production | npm | `sonner` | `2.0.7` | MIT |
| web/default | production | npm | `sse.js` | `2.8.0` | Apache-2.0 |
| web/default | production | npm | `streamdown` | `2.5.0` | Apache-2.0 |
| web/default | production | npm | `tailwind-merge` | `3.5.0` | MIT |
| web/default | production | npm | `tailwindcss` | `4.2.2` | MIT |
| web/default | production | npm | `tokenlens` | `1.3.1` | MIT |
| web/default | production | npm | `tw-animate-css` | `1.4.0` | MIT |
| web/default | production | npm | `use-stick-to-bottom` | `1.1.3` | MIT |
| web/default | production | npm | `vaul` | `1.1.2` | MIT |
| web/default | production | npm | `zod` | `4.3.6` | MIT |
| web/default | production | npm | `zustand` | `5.0.12` | MIT |
| web/default | development | npm | `@eslint/js` | `10.0.1` | MIT |
| web/default | development | npm | `@rsbuild/core` | `2.0.1` | MIT |
| web/default | development | npm | `@rsbuild/plugin-react` | `2.0.0` | MIT |
| web/default | development | npm | `@tanstack/eslint-plugin-query` | `5.97.0` | MIT |
| web/default | development | npm | `@tanstack/react-query-devtools` | `5.97.0` | MIT |
| web/default | development | npm | `@tanstack/react-router-devtools` | `1.166.13` | MIT |
| web/default | development | npm | `@tanstack/router-plugin` | `1.167.23` | MIT |
| web/default | development | npm | `@trivago/prettier-plugin-sort-imports` | `6.0.2` | Apache-2.0 |
| web/default | development | npm | `@types/node` | `25.6.0` | MIT |
| web/default | development | npm | `@types/react` | `19.2.14` | MIT |
| web/default | development | npm | `@types/react-dom` | `19.2.3` | MIT |
| web/default | development | npm | `@xyflow/react` | `12.10.2` | MIT |
| web/default | development | npm | `embla-carousel-react` | `8.6.0` | MIT |
| web/default | development | npm | `eslint` | `10.2.0` | MIT |
| web/default | development | npm | `eslint-plugin-react-hooks` | `7.0.1` | MIT |
| web/default | development | npm | `eslint-plugin-react-refresh` | `0.5.2` | MIT |
| web/default | development | npm | `globals` | `17.4.0` | MIT |
| web/default | development | npm | `knip` | `6.3.1` | ISC |
| web/default | development | npm | `prettier` | `3.8.2` | MIT |
| web/default | development | npm | `prettier-plugin-tailwindcss` | `0.7.2` | MIT |
| web/default | development | npm | `shadcn` | `3.8.5` | MIT |
| web/default | development | npm | `typescript` | `5.9.3` | Apache-2.0 |
| web/default | development | npm | `typescript-eslint` | `8.58.1` | MIT |
| web/classic | production | npm | `@douyinfe/semi-icons` | `2.72.2` | MIT |
| web/classic | production | npm | `@douyinfe/semi-ui` | `2.72.2` | MIT |
| web/classic | production | npm | `@lobehub/icons` | `2.1.0` | MIT |
| web/classic | production | npm | `@visactor/react-vchart` | `1.8.11` | MIT |
| web/classic | production | npm | `@visactor/vchart` | `1.8.11` | MIT |
| web/classic | production | npm | `@visactor/vchart-semi-theme` | `1.8.8` | MIT |
| web/classic | production | npm | `axios` | `1.15.0` | MIT |
| web/classic | production | npm | `clsx` | `2.1.1` | MIT |
| web/classic | production | npm | `dayjs` | `1.11.13` | MIT |
| web/classic | production | npm | `history` | `5.3.0` | MIT |
| web/classic | production | npm | `i18next` | `23.16.8` | MIT |
| web/classic | production | npm | `i18next-browser-languagedetector` | `7.2.2` | MIT |
| web/classic | production | npm | `katex` | `0.16.22` | MIT |
| web/classic | production | npm | `lucide-react` | `0.511.0` | ISC |
| web/classic | production | npm | `marked` | `4.3.0` | MIT |
| web/classic | production | npm | `mermaid` | `11.6.0` | MIT |
| web/classic | production | npm | `qrcode.react` | `4.2.0` | ISC |
| web/classic | production | npm | `react` | `18.3.1` | MIT |
| web/classic | production | npm | `react-dom` | `18.3.1` | MIT |
| web/classic | production | npm | `react-dropzone` | `14.3.5` | MIT |
| web/classic | production | npm | `react-fireworks` | `1.0.4` | ISC |
| web/classic | production | npm | `react-i18next` | `13.5.0` | MIT |
| web/classic | production | npm | `react-icons` | `5.5.0` | MIT |
| web/classic | production | npm | `react-markdown` | `10.1.0` | MIT |
| web/classic | production | npm | `react-router-dom` | `6.28.1` | MIT |
| web/classic | production | npm | `react-telegram-login` | `1.1.2` | MIT |
| web/classic | production | npm | `react-toastify` | `9.1.3` | MIT |
| web/classic | production | npm | `react-turnstile` | `1.1.4` | MIT |
| web/classic | production | npm | `rehype-highlight` | `7.0.2` | MIT |
| web/classic | production | npm | `rehype-katex` | `7.0.1` | MIT |
| web/classic | production | npm | `remark-breaks` | `4.0.0` | MIT |
| web/classic | production | npm | `remark-gfm` | `4.0.1` | MIT |
| web/classic | production | npm | `remark-math` | `6.0.0` | MIT |
| web/classic | production | npm | `sse.js` | `2.6.0` | Apache-2.0 |
| web/classic | production | npm | `unist-util-visit` | `5.0.0` | MIT |
| web/classic | production | npm | `use-debounce` | `10.0.4` | MIT |
| web/classic | development | npm | `@douyinfe/vite-plugin-semi` | `2.74.0-alpha.6` | MIT |
| web/classic | development | npm | `@so1ve/prettier-config` | `3.1.0` | MIT |
| web/classic | development | npm | `@vitejs/plugin-react` | `4.3.4` | MIT |
| web/classic | development | npm | `autoprefixer` | `10.4.21` | MIT |
| web/classic | development | npm | `code-inspector-plugin` | `1.3.3` | MIT |
| web/classic | development | npm | `eslint` | `8.57.0` | MIT |
| web/classic | development | npm | `eslint-plugin-header` | `3.1.1` | MIT |
| web/classic | development | npm | `eslint-plugin-react-hooks` | `5.2.0` | MIT |
| web/classic | development | npm | `i18next-cli` | `1.15.0` | MIT |
| web/classic | development | npm | `postcss` | `8.5.3` | MIT |
| web/classic | development | npm | `prettier` | `3.4.2` | MIT |
| web/classic | development | npm | `tailwindcss` | `3.4.17` | MIT |
| web/classic | development | npm | `typescript` | `4.4.2` | Apache-2.0 |
| web/classic | development | npm | `vite` | `5.4.11` | MIT |
| electron | development | npm | `cross-env` | `7.0.3` | MIT |
| electron | development | npm | `electron` | `39.8.5` | MIT |
| electron | development | npm | `electron-builder` | `26.7.0` | MIT |
## License Texts
### Apache-2.0
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at:
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.
### Apache-2.0 OR MIT
Dual-licensed components may be used under Apache-2.0 or MIT. Both standard license texts are included below.
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at:
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
### BSD-2-Clause
BSD 2-Clause License
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
### BSD-3-Clause
BSD 3-Clause License
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
### ISC
ISC License
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
### MIT
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
### OFL-1.1
SIL Open Font License 1.1
The font dependency listed under OFL-1.1 is licensed under the SIL Open Font
License, Version 1.1. The full license text is available at:
https://openfontlicense.org/open-font-license-official-text/
When distributing font files, preserve the OFL license text, copyright notices,
and reserved font name restrictions supplied by the upstream font project.
### Proprietary/Internal - owned by project maintainer
This dependency is owned by the project maintainer and is not treated as a third-party open source dependency for this review.
### Unlicense
The Unlicense
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute
this software, either in source code form or as a compiled binary, for any
purpose, commercial or non-commercial, and by any means.
For more information, please refer to https://unlicense.org/
+23 -1
View File
@@ -4,6 +4,7 @@ import (
"crypto/tls"
//"os"
//"strconv"
"strings"
"sync"
"sync/atomic"
"time"
@@ -36,6 +37,26 @@ func SetTheme(t string) {
}
}
// ThemeAwarePath rewrites legacy /console/* paths to the default-theme
// equivalents when the active theme is "default". For "classic" (or any
// other theme) the path is returned unchanged. The function only touches
// known prefixes so it is safe to call with arbitrary suffixes and query
// strings.
func ThemeAwarePath(suffix string) string {
if GetTheme() != "default" {
return suffix
}
switch {
case strings.HasPrefix(suffix, "/console/topup"):
return strings.Replace(suffix, "/console/topup", "/wallet", 1)
case strings.HasPrefix(suffix, "/console/log"):
return strings.Replace(suffix, "/console/log", "/usage-logs", 1)
case strings.HasPrefix(suffix, "/console/personal"):
return strings.Replace(suffix, "/console/personal", "/profile", 1)
}
return suffix
}
// var ChatLink = ""
// var ChatLink2 = ""
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
@@ -158,7 +179,8 @@ var GeminiSafetySetting string
var CohereSafetySetting string
const (
RequestIdKey = "X-Oneapi-Request-Id"
RequestIdKey = "X-Oneapi-Request-Id"
UpstreamRequestIdKey = "X-Upstream-Request-Id"
)
const (
+63 -39
View File
@@ -19,6 +19,7 @@ import (
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type OpenAIModel struct {
@@ -68,12 +69,33 @@ func clearChannelInfo(channel *model.Channel) {
}
}
func applyChannelStatusFilter(query *gorm.DB, statusFilter int) *gorm.DB {
if statusFilter == common.ChannelStatusEnabled {
return query.Where("status = ?", common.ChannelStatusEnabled)
}
if statusFilter == 0 {
return query.Where("status != ?", common.ChannelStatusEnabled)
}
return query
}
func buildChannelListQuery(group string, statusFilter int, typeFilter int) *gorm.DB {
query := model.DB.Model(&model.Channel{})
query = model.ApplyChannelGroupFilter(query, group)
query = applyChannelStatusFilter(query, statusFilter)
if typeFilter >= 0 {
query = query.Where("type = ?", typeFilter)
}
return query
}
func GetAllChannels(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
channelData := make([]*model.Channel, 0)
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
sortOptions := model.NewChannelSortOptions(c.Query("sort_by"), c.Query("sort_order"), idSort)
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
groupFilter := model.NormalizeChannelGroupFilter(c.Query("group"))
statusParam := c.Query("status")
// statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual)
statusFilter := parseStatusFilter(statusParam)
@@ -89,50 +111,45 @@ func GetAllChannels(c *gin.Context) {
var total int64
if enableTagMode {
tags, err := model.GetPaginatedTags(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
tags, err := model.GetPaginatedChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter), pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.SysError("failed to get paginated tags: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签失败,请稍后重试"})
return
}
total, err = model.CountChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter))
if err != nil {
common.SysError("failed to count tags: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签数量失败,请稍后重试"})
return
}
for _, tag := range tags {
if tag == nil || *tag == "" {
continue
}
tagChannels, err := model.GetChannelsByTag(*tag, idSort, false, sortOptions)
var tagChannels []*model.Channel
err := sortOptions.Apply(buildChannelListQuery(groupFilter, statusFilter, typeFilter).Where("tag = ?", *tag)).
Omit("key").
Find(&tagChannels).Error
if err != nil {
continue
common.SysError("failed to get channels by tag: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签渠道失败,请稍后重试"})
return
}
filtered := make([]*model.Channel, 0)
for _, ch := range tagChannels {
if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled {
continue
}
if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled {
continue
}
if typeFilter >= 0 && ch.Type != typeFilter {
continue
}
filtered = append(filtered, ch)
}
channelData = append(channelData, filtered...)
channelData = append(channelData, tagChannels...)
}
total, _ = model.CountAllTags()
} else {
baseQuery := model.DB.Model(&model.Channel{})
if typeFilter >= 0 {
baseQuery = baseQuery.Where("type = ?", typeFilter)
}
if statusFilter == common.ChannelStatusEnabled {
baseQuery = baseQuery.Where("status = ?", common.ChannelStatusEnabled)
} else if statusFilter == 0 {
baseQuery = baseQuery.Where("status != ?", common.ChannelStatusEnabled)
if err := buildChannelListQuery(groupFilter, statusFilter, typeFilter).Count(&total).Error; err != nil {
common.SysError("failed to count channels: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道数量失败,请稍后重试"})
return
}
baseQuery.Count(&total)
err := sortOptions.Apply(baseQuery).Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("key").Find(&channelData).Error
err := sortOptions.Apply(buildChannelListQuery(groupFilter, statusFilter, typeFilter)).
Limit(pageInfo.GetPageSize()).
Offset(pageInfo.GetStartIdx()).
Omit("key").
Find(&channelData).Error
if err != nil {
common.SysError("failed to get channels: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道列表失败,请稍后重试"})
@@ -144,17 +161,16 @@ func GetAllChannels(c *gin.Context) {
clearChannelInfo(datum)
}
countQuery := model.DB.Model(&model.Channel{})
if statusFilter == common.ChannelStatusEnabled {
countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled)
} else if statusFilter == 0 {
countQuery = countQuery.Where("status != ?", common.ChannelStatusEnabled)
}
countQuery := buildChannelListQuery(groupFilter, statusFilter, -1)
var results []struct {
Type int64
Count int64
}
_ = countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error
if err := countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error; err != nil {
common.SysError("failed to count channel types: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道类型统计失败,请稍后重试"})
return
}
typeCounts := make(map[int64]int64)
for _, r := range results {
typeCounts[r.Type] = r.Count
@@ -262,10 +278,18 @@ func SearchChannels(c *gin.Context) {
}
for _, tag := range tags {
if tag != nil && *tag != "" {
tagChannel, err := model.GetChannelsByTag(*tag, idSort, false, sortOptions)
if err == nil {
channelData = append(channelData, tagChannel...)
var tagChannels []*model.Channel
err := sortOptions.Apply(buildChannelListQuery(group, -1, -1).Where("tag = ?", *tag)).
Omit("key").
Find(&tagChannels).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
channelData = append(channelData, tagChannels...)
}
}
} else {
+2 -2
View File
@@ -501,7 +501,7 @@ func GetUserOAuthBindingsByAdmin(c *gin.Context) {
}
myRole := c.GetInt("role")
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
if !canManageTargetRole(myRole, targetUser.Role) {
common.ApiErrorMsg(c, "no permission")
return
}
@@ -560,7 +560,7 @@ func UnbindCustomOAuthByAdmin(c *gin.Context) {
}
myRole := c.GetInt("role")
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
if !canManageTargetRole(myRole, targetUser.Role) {
common.ApiErrorMsg(c, "no permission")
return
}
+4 -2
View File
@@ -21,7 +21,8 @@ func GetAllLogs(c *gin.Context) {
channel, _ := strconv.Atoi(c.Query("channel"))
group := c.Query("group")
requestId := c.Query("request_id")
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId)
upstreamRequestId := c.Query("upstream_request_id")
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId, upstreamRequestId)
if err != nil {
common.ApiError(c, err)
return
@@ -42,7 +43,8 @@ func GetUserLogs(c *gin.Context) {
modelName := c.Query("model_name")
group := c.Query("group")
requestId := c.Query("request_id")
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId)
upstreamRequestId := c.Query("upstream_request_id")
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId, upstreamRequestId)
if err != nil {
common.ApiError(c, err)
return
+2
View File
@@ -87,6 +87,8 @@ func GetStatus(c *gin.Context) {
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"register_enabled": common.RegisterEnabled,
"password_register_enabled": common.PasswordRegisterEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
"usd_exchange_rate": operation_setting.USDExchangeRate,
+27 -2
View File
@@ -3,9 +3,11 @@ package controller
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/console_setting"
@@ -27,6 +29,19 @@ var completionRatioMetaOptionKeys = []string{
"AudioCompletionRatio",
}
func isPaymentComplianceOptionKey(key string) bool {
return strings.HasPrefix(key, "payment_setting.compliance_")
}
func isPositiveOptionValue(value string) bool {
intValue, err := strconv.Atoi(strings.TrimSpace(value))
if err == nil {
return intValue > 0
}
floatValue, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
return err == nil && floatValue > 0
}
func isVisiblePublicKeyOption(key string) bool {
switch key {
case "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey":
@@ -104,7 +119,6 @@ func GetOptions(c *gin.Context) {
"message": "",
"data": options,
})
return
}
type OptionUpdateRequest struct {
@@ -133,6 +147,18 @@ func UpdateOption(c *gin.Context) {
option.Value = fmt.Sprintf("%v", option.Value)
}
switch option.Key {
case "QuotaForInviter", "QuotaForInvitee":
if isPositiveOptionValue(option.Value.(string)) && !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}
default:
if isPaymentComplianceOptionKey(option.Key) {
common.ApiErrorMsg(c, "合规确认字段不允许通过通用设置接口修改")
return
}
}
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && common.GitHubClientId == "" {
c.JSON(http.StatusOK, gin.H{
@@ -324,5 +350,4 @@ func UpdateOption(c *gin.Context) {
"success": true,
"message": "",
})
return
}
+5
View File
@@ -350,6 +350,11 @@ func AdminResetPasskey(c *gin.Context) {
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
if !canManageTargetRole(myRole, user.Role) {
common.ApiErrorMsg(c, "no permission")
return
}
if _, err := model.GetPasskeyByUserID(user.Id); err != nil {
if errors.Is(err, model.ErrPasskeyNotFound) {
+82
View File
@@ -0,0 +1,82 @@
package controller
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
type PaymentComplianceRequest struct {
Confirmed bool `json:"confirmed"`
}
func requirePaymentCompliance(c *gin.Context) bool {
if !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return false
}
return true
}
func ConfirmPaymentCompliance(c *gin.Context) {
if c.GetBool("use_access_token") {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "This operation requires dashboard session authentication. API access token is not allowed.",
})
return
}
var req PaymentComplianceRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
common.ApiErrorMsg(c, "参数错误")
return
}
if !req.Confirmed {
common.ApiErrorMsg(c, "请确认合规声明")
return
}
now := time.Now().Unix()
userId := c.GetInt("id")
clientIP := c.ClientIP()
updates := map[string]string{
"payment_setting.compliance_confirmed": "true",
"payment_setting.compliance_terms_version": operation_setting.CurrentComplianceTermsVersion,
"payment_setting.compliance_confirmed_at": strconv.FormatInt(now, 10),
"payment_setting.compliance_confirmed_by": strconv.Itoa(userId),
"payment_setting.compliance_confirmed_ip": clientIP,
}
for key, value := range updates {
if err := model.UpdateOption(key, value); err != nil {
common.ApiError(c, err)
return
}
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf(
"payment compliance confirmed user_id=%d ip=%s terms_version=%s confirmed_at=%d",
userId,
clientIP,
operation_setting.CurrentComplianceTermsVersion,
now,
))
common.ApiSuccess(c, gin.H{
"confirmed": true,
"terms_version": operation_setting.CurrentComplianceTermsVersion,
"confirmed_at": now,
"confirmed_by": userId,
})
}
@@ -7,7 +7,14 @@ import (
"github.com/QuantumNous/new-api/setting/operation_setting"
)
func isPaymentComplianceConfirmed() bool {
return operation_setting.IsPaymentComplianceConfirmed()
}
func isStripeTopUpEnabled() bool {
if !isPaymentComplianceConfirmed() {
return false
}
return strings.TrimSpace(setting.StripeApiSecret) != "" &&
strings.TrimSpace(setting.StripeWebhookSecret) != "" &&
strings.TrimSpace(setting.StripePriceId) != ""
@@ -22,6 +29,9 @@ func isStripeWebhookEnabled() bool {
}
func isCreemTopUpEnabled() bool {
if !isPaymentComplianceConfirmed() {
return false
}
products := strings.TrimSpace(setting.CreemProducts)
return strings.TrimSpace(setting.CreemApiKey) != "" &&
products != "" &&
@@ -37,6 +47,9 @@ func isCreemWebhookEnabled() bool {
}
func isWaffoTopUpEnabled() bool {
if !isPaymentComplianceConfirmed() {
return false
}
if !setting.WaffoEnabled {
return false
}
@@ -61,6 +74,9 @@ func isWaffoWebhookEnabled() bool {
}
func isWaffoPancakeTopUpEnabled() bool {
if !isPaymentComplianceConfirmed() {
return false
}
if !setting.WaffoPancakeEnabled {
return false
}
@@ -86,6 +102,9 @@ func isWaffoPancakeWebhookEnabled() bool {
}
func isEpayTopUpEnabled() bool {
if !isPaymentComplianceConfirmed() {
return false
}
return isEpayWebhookConfigured() && len(operation_setting.PayMethods) > 0
}
@@ -8,7 +8,21 @@ import (
"github.com/stretchr/testify/require"
)
func confirmPaymentComplianceForTest(t *testing.T) {
t.Helper()
paymentSetting := operation_setting.GetPaymentSetting()
originalConfirmed := paymentSetting.ComplianceConfirmed
originalTermsVersion := paymentSetting.ComplianceTermsVersion
t.Cleanup(func() {
paymentSetting.ComplianceConfirmed = originalConfirmed
paymentSetting.ComplianceTermsVersion = originalTermsVersion
})
paymentSetting.ComplianceConfirmed = true
paymentSetting.ComplianceTermsVersion = operation_setting.CurrentComplianceTermsVersion
}
func TestStripeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
confirmPaymentComplianceForTest(t)
originalAPISecret := setting.StripeApiSecret
originalWebhookSecret := setting.StripeWebhookSecret
originalPriceID := setting.StripePriceId
@@ -31,6 +45,7 @@ func TestStripeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
}
func TestCreemWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
confirmPaymentComplianceForTest(t)
originalAPIKey := setting.CreemApiKey
originalProducts := setting.CreemProducts
originalWebhookSecret := setting.CreemWebhookSecret
@@ -53,6 +68,7 @@ func TestCreemWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
}
func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
confirmPaymentComplianceForTest(t)
originalEnabled := setting.WaffoEnabled
originalSandbox := setting.WaffoSandbox
originalAPIKey := setting.WaffoApiKey
@@ -97,6 +113,7 @@ func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
}
func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
confirmPaymentComplianceForTest(t)
originalEnabled := setting.WaffoPancakeEnabled
originalSandbox := setting.WaffoPancakeSandbox
originalMerchantID := setting.WaffoPancakeMerchantID
@@ -141,6 +158,7 @@ func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
}
func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
confirmPaymentComplianceForTest(t)
originalPayAddress := operation_setting.PayAddress
originalEpayID := operation_setting.EpayId
originalEpayKey := operation_setting.EpayKey
+14 -1
View File
@@ -5,8 +5,10 @@ import (
"strconv"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
func GetPerfMetricsSummary(c *gin.Context) {
@@ -17,7 +19,8 @@ func GetPerfMetricsSummary(c *gin.Context) {
}
}
result, err := perfmetrics.QuerySummaryAll(hours)
activeGroups := append(lo.Keys(ratio_setting.GetGroupRatioCopy()), "auto")
result, err := perfmetrics.QuerySummaryAll(hours, activeGroups)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
@@ -62,8 +65,18 @@ func GetPerfMetrics(c *gin.Context) {
return
}
result.Groups = filterActiveGroups(result.Groups)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
}
func filterActiveGroups(groups []perfmetrics.GroupResult) []perfmetrics.GroupResult {
activeRatios := ratio_setting.GetGroupRatioCopy()
return lo.Filter(groups, func(g perfmetrics.GroupResult, _ int) bool {
_, ok := activeRatios[g.Group]
return ok || g.Group == "auto"
})
}
+6
View File
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
@@ -59,6 +60,11 @@ func GetRedemption(c *gin.Context) {
}
func AddRedemption(c *gin.Context) {
if !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}
redemption := model.Redemption{}
err := c.ShouldBindJSON(&redemption)
if err != nil {
+13
View File
@@ -0,0 +1,13 @@
package controller
import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
)
func paymentReturnPath(suffix string) string {
base := strings.TrimRight(system_setting.ServerAddress, "/")
return base + common.ThemeAwarePath(suffix)
}
+26
View File
@@ -6,6 +6,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -24,6 +25,11 @@ type BillingPreferenceRequest struct {
// ---- User APIs ----
func GetSubscriptionPlans(c *gin.Context) {
if !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiSuccess(c, []SubscriptionPlanDTO{})
return
}
var plans []model.SubscriptionPlan
if err := model.DB.Where("enabled = ?", true).Order("sort_order desc, id desc").Find(&plans).Error; err != nil {
common.ApiError(c, err)
@@ -108,6 +114,10 @@ type AdminUpsertSubscriptionPlanRequest struct {
}
func AdminCreateSubscriptionPlan(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
var req AdminUpsertSubscriptionPlanRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "参数错误")
@@ -166,6 +176,10 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
}
func AdminUpdateSubscriptionPlan(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
common.ApiErrorMsg(c, "无效的ID")
@@ -259,6 +273,10 @@ type AdminUpdateSubscriptionPlanStatusRequest struct {
}
func AdminUpdateSubscriptionPlanStatus(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
common.ApiErrorMsg(c, "无效的ID")
@@ -283,6 +301,10 @@ type AdminBindSubscriptionRequest struct {
}
func AdminBindSubscription(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
var req AdminBindSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil || req.UserId <= 0 || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
@@ -322,6 +344,10 @@ type AdminCreateUserSubscriptionRequest struct {
// AdminCreateUserSubscription creates a new user subscription from a plan (no payment).
func AdminCreateUserSubscription(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
userId, _ := strconv.Atoi(c.Param("id"))
if userId <= 0 {
common.ApiErrorMsg(c, "无效的用户ID")
+4
View File
@@ -21,6 +21,10 @@ type SubscriptionCreemPayRequest struct {
}
func SubscriptionRequestCreemPay(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
var req SubscriptionCreemPayRequest
// Keep body for debugging consistency (like RequestCreemPay)
+11 -8
View File
@@ -12,7 +12,6 @@ import (
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
@@ -23,6 +22,10 @@ type SubscriptionEpayPayRequest struct {
}
func SubscriptionRequestEpay(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
var req SubscriptionEpayPayRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
@@ -173,7 +176,7 @@ func SubscriptionEpayReturn(c *gin.Context) {
if c.Request.Method == "POST" {
// POST 请求:从 POST body 解析参数
if err := c.Request.ParseForm(); err != nil {
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
return
}
params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
@@ -189,29 +192,29 @@ func SubscriptionEpayReturn(c *gin.Context) {
}
if len(params) == 0 {
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
return
}
client := GetEpayClient()
if client == nil {
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
return
}
verifyInfo, err := client.Verify(params)
if err != nil || !verifyInfo.VerifyStatus {
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
return
}
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
LockOrder(verifyInfo.ServiceTradeNo)
defer UnlockOrder(verifyInfo.ServiceTradeNo)
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), model.PaymentProviderEpay, verifyInfo.Type); err != nil {
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
return
}
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=success")
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=success"))
return
}
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=pending")
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=pending"))
}
+6 -3
View File
@@ -10,7 +10,6 @@ import (
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/checkout/session"
@@ -22,6 +21,10 @@ type SubscriptionStripePayRequest struct {
}
func SubscriptionRequestStripePay(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
var req SubscriptionStripePayRequest
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
@@ -111,8 +114,8 @@ func genStripeSubscriptionLink(referenceId string, customerId string, email stri
params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(referenceId),
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
CancelURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
SuccessURL: stripe.String(paymentReturnPath("/console/topup")),
CancelURL: stripe.String(paymentReturnPath("/console/topup")),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceId),
+3 -3
View File
@@ -96,13 +96,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
}
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask response: %s", string(responseBody)))
logger.LogDebug(ctx, "UpdateVideoSingleTask response: %s", responseBody)
taskResult := &relaycommon.TaskInfo{}
// try parse as New API response format
var responseItems dto.TaskResponse[model.Task]
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask parsed as new api response format: %+v", responseItems))
logger.LogDebug(ctx, "UpdateVideoSingleTask parsed as new api response format: %+v", responseItems)
t := responseItems.Data
taskResult.TaskID = t.TaskID
taskResult.Status = string(t.Status)
@@ -116,7 +116,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
task.Data = redactVideoResponseBody(responseBody)
}
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask taskResult: %+v", taskResult))
logger.LogDebug(ctx, "UpdateVideoSingleTask taskResult: %+v", taskResult)
now := time.Now().Unix()
if taskResult.Status == "" {
+1 -1
View File
@@ -66,7 +66,7 @@ func TelegramBind(c *gin.Context) {
return
}
c.Redirect(302, "/console/personal")
c.Redirect(302, common.ThemeAwarePath("/console/personal"))
}
func TelegramLogin(c *gin.Context) {
+14 -7
View File
@@ -14,7 +14,6 @@ import (
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/Calcium-Ion/go-epay/epay"
"github.com/gin-gonic/gin"
@@ -23,8 +22,13 @@ import (
)
func GetTopUpInfo(c *gin.Context) {
complianceConfirmed := operation_setting.IsPaymentComplianceConfirmed()
// 获取支付方式
payMethods := operation_setting.PayMethods
if !complianceConfirmed {
payMethods = []map[string]string{}
}
// 如果启用了 Stripe 支付,添加到支付方法列表
if isStripeTopUpEnabled() {
@@ -91,11 +95,14 @@ func GetTopUpInfo(c *gin.Context) {
}
data := gin.H{
"enable_online_topup": isEpayTopUpEnabled(),
"enable_stripe_topup": isStripeTopUpEnabled(),
"enable_creem_topup": isCreemTopUpEnabled(),
"enable_waffo_topup": enableWaffo,
"enable_waffo_pancake_topup": enableWaffoPancake,
"enable_online_topup": isEpayTopUpEnabled(),
"enable_stripe_topup": isStripeTopUpEnabled(),
"enable_creem_topup": isCreemTopUpEnabled(),
"enable_waffo_topup": enableWaffo,
"enable_waffo_pancake_topup": enableWaffoPancake,
"enable_redemption": complianceConfirmed,
"payment_compliance_confirmed": complianceConfirmed,
"payment_compliance_terms_version": operation_setting.CurrentComplianceTermsVersion,
"waffo_pay_methods": func() interface{} {
if enableWaffo {
return setting.GetWaffoPayMethods()
@@ -208,7 +215,7 @@ func RequestEpay(c *gin.Context) {
}
callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log")
returnUrl, _ := url.Parse(paymentReturnPath("/console/log"))
notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
+2 -3
View File
@@ -15,7 +15,6 @@ import (
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/stripe/stripe-go/v81"
@@ -348,10 +347,10 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
// Use custom URLs if provided, otherwise use defaults
if successURL == "" {
successURL = system_setting.ServerAddress + "/console/log"
successURL = paymentReturnPath("/console/log")
}
if cancelURL == "" {
cancelURL = system_setting.ServerAddress + "/console/topup"
cancelURL = paymentReturnPath("/console/topup")
}
params := &stripe.CheckoutSessionParams{
+1 -2
View File
@@ -14,7 +14,6 @@ import (
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/thanhpk/randstr"
waffo "github.com/waffo-com/waffo-go"
@@ -237,7 +236,7 @@ func RequestWaffoPay(c *gin.Context) {
if setting.WaffoNotifyUrl != "" {
notifyUrl = setting.WaffoNotifyUrl
}
returnUrl := system_setting.ServerAddress + "/console/topup?show_history=true"
returnUrl := paymentReturnPath("/console/topup?show_history=true")
if setting.WaffoReturnUrl != "" {
returnUrl = setting.WaffoReturnUrl
}
+1 -2
View File
@@ -13,7 +13,6 @@ import (
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"github.com/thanhpk/randstr"
@@ -107,7 +106,7 @@ func getWaffoPancakeReturnURL() string {
if strings.TrimSpace(setting.WaffoPancakeReturnURL) != "" {
return setting.WaffoPancakeReturnURL
}
return strings.TrimRight(system_setting.ServerAddress, "/") + "/console/topup?show_history=true"
return paymentReturnPath("/console/topup?show_history=true")
}
func RequestWaffoPancakePay(c *gin.Context) {
+1 -1
View File
@@ -520,7 +520,7 @@ func AdminDisable2FA(c *gin.Context) {
}
myRole := c.GetInt("role")
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
if !canManageTargetRole(myRole, targetUser.Role) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权操作同级或更高级用户的2FA设置",
+25 -9
View File
@@ -17,6 +17,7 @@ import (
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/constant"
@@ -263,6 +264,10 @@ func SearchUsers(c *gin.Context) {
return
}
func canManageTargetRole(myRole int, targetRole int) bool {
return myRole == common.RoleRootUser || myRole > targetRole
}
func GetUser(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
@@ -275,7 +280,7 @@ func GetUser(c *gin.Context) {
return
}
myRole := c.GetInt("role")
if myRole <= user.Role && myRole != common.RoleRootUser {
if !canManageTargetRole(myRole, user.Role) {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
return
}
@@ -327,6 +332,10 @@ type TransferAffQuotaRequest struct {
}
func TransferAffQuota(c *gin.Context) {
if !requirePaymentCompliance(c) {
return
}
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
@@ -562,11 +571,11 @@ func UpdateUser(c *gin.Context) {
return
}
myRole := c.GetInt("role")
if myRole <= originUser.Role && myRole != common.RoleRootUser {
if !canManageTargetRole(myRole, originUser.Role) {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return
}
if myRole <= updatedUser.Role && myRole != common.RoleRootUser {
if !canManageTargetRole(myRole, updatedUser.Role) {
common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
return
}
@@ -605,7 +614,7 @@ func AdminClearUserBinding(c *gin.Context) {
}
myRole := c.GetInt("role")
if myRole <= user.Role && myRole != common.RoleRootUser {
if !canManageTargetRole(myRole, user.Role) {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
return
}
@@ -773,12 +782,14 @@ func DeleteUser(c *gin.Context) {
}
err = model.HardDeleteUserById(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func DeleteSelf(c *gin.Context) {
@@ -867,7 +878,7 @@ func ManageUser(c *gin.Context) {
return
}
myRole := c.GetInt("role")
if myRole <= user.Role && myRole != common.RoleRootUser {
if !canManageTargetRole(myRole, user.Role) {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return
}
@@ -1081,6 +1092,11 @@ func getTopUpLock(userID int) *topUpTryLock {
}
func TopUp(c *gin.Context) {
if !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}
id := c.GetInt("id")
lock := getTopUpLock(id)
if !lock.TryLock() {
Generated Vendored
+3 -3
View File
@@ -3097,9 +3097,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"dev": true,
"license": "MIT",
"engines": {
+61 -1
View File
@@ -63,6 +63,26 @@
{
"from": "../web/dist",
"to": "web/dist"
},
{
"from": "../LICENSE",
"to": "licenses/LICENSE"
},
{
"from": "../NOTICE",
"to": "licenses/NOTICE"
},
{
"from": "../THIRD-PARTY-LICENSES.md",
"to": "licenses/THIRD-PARTY-LICENSES.md"
},
{
"from": "node_modules/electron/dist/LICENSE",
"to": "licenses/electron/LICENSE"
},
{
"from": "node_modules/electron/dist/LICENSES.chromium.html",
"to": "licenses/electron/LICENSES.chromium.html"
}
]
},
@@ -76,6 +96,26 @@
{
"from": "../new-api.exe",
"to": "bin/new-api.exe"
},
{
"from": "../LICENSE",
"to": "licenses/LICENSE"
},
{
"from": "../NOTICE",
"to": "licenses/NOTICE"
},
{
"from": "../THIRD-PARTY-LICENSES.md",
"to": "licenses/THIRD-PARTY-LICENSES.md"
},
{
"from": "node_modules/electron/dist/LICENSE",
"to": "licenses/electron/LICENSE"
},
{
"from": "node_modules/electron/dist/LICENSES.chromium.html",
"to": "licenses/electron/LICENSES.chromium.html"
}
]
},
@@ -90,6 +130,26 @@
{
"from": "../new-api",
"to": "bin/new-api"
},
{
"from": "../LICENSE",
"to": "licenses/LICENSE"
},
{
"from": "../NOTICE",
"to": "licenses/NOTICE"
},
{
"from": "../THIRD-PARTY-LICENSES.md",
"to": "licenses/THIRD-PARTY-LICENSES.md"
},
{
"from": "node_modules/electron/dist/LICENSE",
"to": "licenses/electron/LICENSE"
},
{
"from": "node_modules/electron/dist/LICENSES.chromium.html",
"to": "licenses/electron/LICENSES.chromium.html"
}
]
},
@@ -98,4 +158,4 @@
"allowToChangeInstallationDirectory": true
}
}
}
}
+11 -10
View File
@@ -142,16 +142,17 @@ const (
// Payment related messages
const (
MsgPaymentNotConfigured = "payment.not_configured"
MsgPaymentMethodNotExists = "payment.method_not_exists"
MsgPaymentCallbackError = "payment.callback_error"
MsgPaymentCreateFailed = "payment.create_failed"
MsgPaymentStartFailed = "payment.start_failed"
MsgPaymentAmountTooLow = "payment.amount_too_low"
MsgPaymentStripeNotConfig = "payment.stripe_not_configured"
MsgPaymentWebhookNotConfig = "payment.webhook_not_configured"
MsgPaymentPriceIdNotConfig = "payment.price_id_not_configured"
MsgPaymentCreemNotConfig = "payment.creem_not_configured"
MsgPaymentNotConfigured = "payment.not_configured"
MsgPaymentMethodNotExists = "payment.method_not_exists"
MsgPaymentCallbackError = "payment.callback_error"
MsgPaymentCreateFailed = "payment.create_failed"
MsgPaymentStartFailed = "payment.start_failed"
MsgPaymentAmountTooLow = "payment.amount_too_low"
MsgPaymentStripeNotConfig = "payment.stripe_not_configured"
MsgPaymentWebhookNotConfig = "payment.webhook_not_configured"
MsgPaymentPriceIdNotConfig = "payment.price_id_not_configured"
MsgPaymentCreemNotConfig = "payment.creem_not_configured"
MsgPaymentComplianceRequired = "payment.compliance_required"
)
// Topup related messages
+1
View File
@@ -134,6 +134,7 @@ payment.stripe_not_configured: "Stripe is not configured or key is invalid"
payment.webhook_not_configured: "Webhook is not configured"
payment.price_id_not_configured: "StripePriceId is not configured for this plan"
payment.creem_not_configured: "CreemProductId is not configured for this plan"
payment.compliance_required: "Payment, redemption, subscription, and invitation reward features are disabled. The administrator must confirm compliance terms before enabling them."
# Topup messages
topup.not_provided: "Payment order number not provided"
+1
View File
@@ -135,6 +135,7 @@ payment.stripe_not_configured: "Stripe 未配置或密钥无效"
payment.webhook_not_configured: "Webhook 未配置"
payment.price_id_not_configured: "该套餐未配置 StripePriceId"
payment.creem_not_configured: "该套餐未配置 CreemProductId"
payment.compliance_required: "支付、兑换码、订阅计划和邀请返利功能已禁用。管理员需先确认合规声明后方可启用。"
# Topup messages
topup.not_provided: "未提供支付单号"
+1
View File
@@ -135,6 +135,7 @@ payment.stripe_not_configured: "Stripe 未設定或密鑰無效"
payment.webhook_not_configured: "Webhook 未設定"
payment.price_id_not_configured: "該訂閱方案未設定 StripePriceId"
payment.creem_not_configured: "該訂閱方案未設定 CreemProductId"
payment.compliance_required: "支付、兌換碼、訂閱方案和邀請返利功能已停用。管理員需先確認合規聲明後方可啟用。"
# Topup messages
topup.not_provided: "未提供支付單號"
+9 -4
View File
@@ -95,9 +95,11 @@ func LogDebug(ctx context.Context, msg string, args ...any) {
}
func logHelper(ctx context.Context, level string, msg string) {
id := ctx.Value(common.RequestIdKey)
if id == nil {
id = "SYSTEM"
var id any = "SYSTEM"
if ctx != nil {
if requestID := ctx.Value(common.RequestIdKey); requestID != nil {
id = requestID
}
}
now := time.Now()
common.LogWriterMu.RLock()
@@ -172,10 +174,13 @@ func FormatQuota(quota int) string {
// LogJson 仅供测试使用 only for test
func LogJson(ctx context.Context, msg string, obj any) {
if !common.DebugEnabled {
return
}
jsonStr, err := common.Marshal(obj)
if err != nil {
LogError(ctx, fmt.Sprintf("json marshal failed: %s", err.Error()))
return
}
LogDebug(ctx, fmt.Sprintf("%s | %s", msg, string(jsonStr)))
LogDebug(ctx, "%s | %s", msg, jsonStr)
}
+36 -2
View File
@@ -1,8 +1,14 @@
FRONTEND_DIR = ./web/default
FRONTEND_CLASSIC_DIR = ./web/classic
BACKEND_DIR = .
DEV_COMPOSE_FILE = docker-compose.dev.yml
DEV_POSTGRES_SERVICE = postgres
DEV_BACKEND_SERVICE = new-api
DEV_POSTGRES_DB = new-api
DEV_POSTGRES_USER = root
DEV_SQLITE_PATH ?= one-api.db
.PHONY: all build-frontend build-frontend-classic build-all-frontends start-backend dev dev-api dev-web dev-web-classic
.PHONY: all build-frontend build-frontend-classic build-all-frontends start-backend dev dev-api dev-api-rebuild dev-web dev-web-classic reset-setup
all: build-all-frontends start-backend
@@ -22,7 +28,11 @@ start-backend:
dev-api:
@echo "Starting backend services (docker)..."
@docker compose -f docker-compose.dev.yml up -d
@docker compose -f $(DEV_COMPOSE_FILE) up -d
dev-api-rebuild:
@echo "Rebuilding and starting backend service (docker)..."
@docker compose -f $(DEV_COMPOSE_FILE) up -d --build $(DEV_BACKEND_SERVICE)
dev-web:
@echo "Starting frontend dev server..."
@@ -33,3 +43,27 @@ dev-web-classic:
@cd $(FRONTEND_CLASSIC_DIR) && bun install && bun run dev
dev: dev-api dev-web
reset-setup:
@echo "Resetting local setup wizard state..."
@if docker compose -f $(DEV_COMPOSE_FILE) ps --services --status running | grep -qx "$(DEV_POSTGRES_SERVICE)"; then \
echo "Detected running docker dev PostgreSQL. Removing setup record and root users..."; \
docker compose -f $(DEV_COMPOSE_FILE) exec -T $(DEV_POSTGRES_SERVICE) \
psql -U $(DEV_POSTGRES_USER) -d $(DEV_POSTGRES_DB) \
-c 'DELETE FROM setups;' \
-c 'DELETE FROM users WHERE role = 100;' \
-c "DELETE FROM options WHERE key IN ('SelfUseModeEnabled', 'DemoSiteEnabled');"; \
echo "Restarting docker dev backend so setup status is recalculated..."; \
docker compose -f $(DEV_COMPOSE_FILE) restart $(DEV_BACKEND_SERVICE); \
elif db_path="$${SQLITE_PATH:-$(DEV_SQLITE_PATH)}"; db_path="$${db_path%%\?*}"; [ -f "$$db_path" ]; then \
db_path="$${SQLITE_PATH:-$(DEV_SQLITE_PATH)}"; \
db_path="$${db_path%%\?*}"; \
echo "Detected local SQLite database: $$db_path"; \
sqlite3 "$$db_path" \
"DELETE FROM setups; DELETE FROM users WHERE role = 100; DELETE FROM options WHERE key IN ('SelfUseModeEnabled', 'DemoSiteEnabled');"; \
echo "SQLite setup state reset. Restart the local backend process before testing the setup wizard."; \
else \
echo "No running docker dev PostgreSQL or local SQLite database found."; \
echo "Start the dev stack with 'make dev-api', or set SQLITE_PATH/DEV_SQLITE_PATH to your local SQLite database."; \
exit 1; \
fi
+135
View File
@@ -0,0 +1,135 @@
package middleware
import (
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
type headerNavAccess struct {
Enabled bool
RequireAuth bool
}
func getHeaderNavAccess(module string) headerNavAccess {
fallback := headerNavAccess{
Enabled: true,
RequireAuth: false,
}
common.OptionMapRWMutex.RLock()
raw := common.OptionMap["HeaderNavModules"]
common.OptionMapRWMutex.RUnlock()
if strings.TrimSpace(raw) == "" {
return fallback
}
var parsed map[string]any
if err := common.Unmarshal([]byte(raw), &parsed); err != nil {
return fallback
}
return parseHeaderNavAccess(parsed[module], fallback)
}
func parseHeaderNavAccess(raw any, fallback headerNavAccess) headerNavAccess {
switch value := raw.(type) {
case bool:
return headerNavAccess{
Enabled: value,
RequireAuth: fallback.RequireAuth,
}
case string:
return headerNavAccess{
Enabled: parseHeaderNavBool(value, fallback.Enabled),
RequireAuth: fallback.RequireAuth,
}
case float64:
return headerNavAccess{
Enabled: parseHeaderNavBool(value, fallback.Enabled),
RequireAuth: fallback.RequireAuth,
}
case map[string]any:
access := fallback
if enabled, ok := value["enabled"]; ok {
access.Enabled = parseHeaderNavBool(enabled, fallback.Enabled)
}
if requireAuth, ok := value["requireAuth"]; ok {
access.RequireAuth = parseHeaderNavBool(requireAuth, fallback.RequireAuth)
}
return access
default:
return fallback
}
}
func parseHeaderNavBool(value any, fallback bool) bool {
switch v := value.(type) {
case bool:
return v
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1":
return true
case "false", "0":
return false
default:
return fallback
}
case float64:
if v == 1 {
return true
}
if v == 0 {
return false
}
return fallback
case int:
if v == 1 {
return true
}
if v == 0 {
return false
}
return fallback
default:
return fallback
}
}
func HeaderNavModuleAuth(module string) gin.HandlerFunc {
return func(c *gin.Context) {
access := getHeaderNavAccess(module)
if !access.Enabled {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": fmt.Sprintf("%s is disabled", module),
})
c.Abort()
return
}
if access.RequireAuth {
UserAuth()(c)
return
}
TryUserAuth()(c)
}
}
func HeaderNavModulePublicOrUserAuth(module string) gin.HandlerFunc {
return func(c *gin.Context) {
access := getHeaderNavAccess(module)
if !access.Enabled || access.RequireAuth {
UserAuth()(c)
return
}
TryUserAuth()(c)
}
}
+167
View File
@@ -0,0 +1,167 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func withHeaderNavModules(t *testing.T, raw string) {
t.Helper()
common.OptionMapRWMutex.Lock()
if common.OptionMap == nil {
common.OptionMap = map[string]string{}
}
previous, hadPrevious := common.OptionMap["HeaderNavModules"]
common.OptionMap["HeaderNavModules"] = raw
common.OptionMapRWMutex.Unlock()
t.Cleanup(func() {
common.OptionMapRWMutex.Lock()
defer common.OptionMapRWMutex.Unlock()
if hadPrevious {
common.OptionMap["HeaderNavModules"] = previous
return
}
delete(common.OptionMap, "HeaderNavModules")
})
}
func performHeaderNavRequest(t *testing.T, handler gin.HandlerFunc, authenticated bool) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(sessions.Sessions("session", cookie.NewStore([]byte("header-nav-test"))))
router.GET("/login", func(c *gin.Context) {
session := sessions.Default(c)
session.Set("username", "tester")
session.Set("role", common.RoleCommonUser)
session.Set("id", 1)
session.Set("status", common.UserStatusEnabled)
session.Set("group", "default")
if err := session.Save(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
c.Status(http.StatusNoContent)
})
router.GET("/api/test", handler, func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
})
var cookies []*http.Cookie
if authenticated {
loginRecorder := httptest.NewRecorder()
loginRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
router.ServeHTTP(loginRecorder, loginRequest)
require.Equal(t, http.StatusNoContent, loginRecorder.Code)
cookies = loginRecorder.Result().Cookies()
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/test", nil)
if authenticated {
request.Header.Set("New-Api-User", "1")
for _, cookie := range cookies {
request.AddCookie(cookie)
}
}
router.ServeHTTP(recorder, request)
return recorder
}
func TestHeaderNavModuleAuthAllowsDefaultPublicAccess(t *testing.T) {
withHeaderNavModules(t, "")
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
require.Equal(t, http.StatusOK, recorder.Code)
}
func TestHeaderNavModuleAuthRejectsDisabledPricing(t *testing.T) {
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
require.Equal(t, http.StatusForbidden, recorder.Code)
}
func TestHeaderNavModuleAuthRequiresLoginForPricing(t *testing.T) {
raw := `{"pricing":{"enabled":true,"requireAuth":true}}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
require.Equal(t, http.StatusUnauthorized, recorder.Code)
}
func TestHeaderNavModuleAuthRequiresLoginForRankings(t *testing.T) {
raw := `{"rankings":{"enabled":true,"requireAuth":true}}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("rankings"), false)
require.Equal(t, http.StatusUnauthorized, recorder.Code)
}
func TestHeaderNavModuleAuthRejectsLegacyDisabledModule(t *testing.T) {
raw := `{"rankings":false}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("rankings"), false)
require.Equal(t, http.StatusForbidden, recorder.Code)
}
func TestHeaderNavModulePublicOrUserAuthAllowsDefaultPublicAccess(t *testing.T) {
withHeaderNavModules(t, "")
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
require.Equal(t, http.StatusOK, recorder.Code)
}
func TestHeaderNavModulePublicOrUserAuthRequiresLoginWhenDisabled(t *testing.T) {
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
require.Equal(t, http.StatusUnauthorized, recorder.Code)
}
func TestHeaderNavModulePublicOrUserAuthAllowsLoggedInWhenDisabled(t *testing.T) {
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), true)
require.Equal(t, http.StatusOK, recorder.Code)
}
func TestHeaderNavModulePublicOrUserAuthRequiresLoginWhenRequireAuth(t *testing.T) {
raw := `{"pricing":{"enabled":true,"requireAuth":true}}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
require.Equal(t, http.StatusUnauthorized, recorder.Code)
}
func TestHeaderNavModulePublicOrUserAuthRequiresLoginForLegacyDisabledModule(t *testing.T) {
raw := `{"pricing":false}`
withHeaderNavModules(t, raw)
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
require.Equal(t, http.StatusUnauthorized, recorder.Code)
}
+92 -44
View File
@@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/types"
"github.com/samber/lo"
@@ -128,6 +129,38 @@ func resolveChannelSortOptions(idSort bool, sortOptions []ChannelSortOptions) Ch
return options
}
func NormalizeChannelGroupFilter(group string) string {
group = strings.TrimSpace(group)
if group == "" || strings.EqualFold(group, "all") || strings.EqualFold(group, "null") {
return ""
}
return group
}
func channelGroupFilterCondition() string {
if common.UsingMySQL {
return `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ? ESCAPE '!'`
}
return `(',' || ` + commonGroupCol + ` || ',') LIKE ? ESCAPE '!'`
}
func channelGroupFilterPattern(group string) string {
group = strings.NewReplacer(
"!", "!!",
"%", "!%",
"_", "!_",
).Replace(group)
return "%," + group + ",%"
}
func ApplyChannelGroupFilter(query *gorm.DB, group string) *gorm.DB {
group = NormalizeChannelGroupFilter(group)
if group == "" {
return query
}
return query.Where(channelGroupFilterCondition(), channelGroupFilterPattern(group))
}
// Value implements driver.Valuer interface
func (c ChannelInfo) Value() (driver.Value, error) {
return common.Marshal(&c)
@@ -218,10 +251,9 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
if err != nil {
return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
//println("before polling index:", channel.ChannelInfo.MultiKeyPollingIndex)
defer func() {
if common.DebugEnabled {
println(fmt.Sprintf("channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex))
logger.LogDebug(nil, "channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex)
}
if !common.MemoryCacheEnabled {
_ = channel.SaveChannelInfo()
@@ -365,25 +397,12 @@ func SearchChannels(keyword string, group string, model string, idSort bool, sor
baseQuery := DB.Model(&Channel{}).Omit("key")
// 构造WHERE子句
var whereClause string
var args []interface{}
if group != "" && group != "null" {
var groupCondition string
if common.UsingMySQL {
groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?`
} else {
// sqlite, PostgreSQL
groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?`
}
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%")
} else {
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%")
}
whereClause := "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
args := []any{common.String2Int(keyword), "%" + keyword + "%", keyword, "%" + keyword + "%", "%" + model + "%"}
baseQuery = ApplyChannelGroupFilter(baseQuery.Where(whereClause, args...), group)
// 执行查询
err := order.Apply(baseQuery.Where(whereClause, args...)).Find(&channels).Error
err := order.Apply(baseQuery).Find(&channels).Error
if err != nil {
return nil, err
}
@@ -401,9 +420,6 @@ func GetChannelById(id int, selectAll bool) (*Channel, error) {
if err != nil {
return nil, err
}
if channel == nil {
return nil, errors.New("channel not found")
}
return channel, nil
}
@@ -627,13 +643,25 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason
if len(keys) == 0 {
channel.Status = status
} else {
var keyIndex int
keyIndex := -1
for i, key := range keys {
if key == usingKey {
keyIndex = i
break
}
}
if keyIndex < 0 {
if usingKey != "" {
common.SysLog(fmt.Sprintf("failed to update multi-key status: channel_id=%d, using key not found", channel.Id))
return
}
channel.Status = status
info := channel.GetOtherInfo()
info["status_reason"] = reason
info["status_time"] = common.GetTimestamp()
channel.SetOtherInfo(info)
return
}
if channel.ChannelInfo.MultiKeyStatusList == nil {
channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
}
@@ -650,16 +678,31 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason
channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = reason
channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp()
}
if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize {
if !hasEnabledMultiKey(keys, channel.ChannelInfo.MultiKeyStatusList) {
channel.Status = common.ChannelStatusAutoDisabled
info := channel.GetOtherInfo()
info["status_reason"] = "All keys are disabled"
info["status_time"] = common.GetTimestamp()
channel.SetOtherInfo(info)
} else if status == common.ChannelStatusEnabled {
channel.Status = common.ChannelStatusEnabled
}
}
}
func hasEnabledMultiKey(keys []string, statusList map[int]int) bool {
for i := range keys {
if statusList == nil {
return true
}
status, ok := statusList[i]
if !ok || status == common.ChannelStatusEnabled {
return true
}
}
return false
}
func UpdateChannelStatus(channelId int, usingKey string, status int, reason string) bool {
if common.MemoryCacheEnabled {
channelStatusLock.Lock()
@@ -671,11 +714,15 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
}
if channelCache.ChannelInfo.IsMultiKey {
// Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey
beforeStatus := channelCache.Status
pollingLock := GetChannelPollingLock(channelId)
pollingLock.Lock()
// 如果是多Key模式,更新缓存中的状态
handlerMultiKeyUpdate(channelCache, usingKey, status, reason)
pollingLock.Unlock()
if beforeStatus != channelCache.Status {
CacheUpdateChannelStatus(channelId, channelCache.Status)
}
//CacheUpdateChannel(channelCache)
//return true
} else {
@@ -758,7 +805,7 @@ func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *
updateData.Tag = newTag
updatedTag = *newTag
}
if modelMapping != nil && *modelMapping != "" {
if modelMapping != nil {
updateData.ModelMapping = modelMapping
}
if models != nil && *models != "" {
@@ -831,8 +878,18 @@ func DeleteDisabledChannel() (int64, error) {
}
func GetPaginatedTags(offset int, limit int) ([]*string, error) {
return GetPaginatedChannelTags(DB.Model(&Channel{}), offset, limit)
}
func GetPaginatedChannelTags(query *gorm.DB, offset int, limit int) ([]*string, error) {
var tags []*string
err := DB.Model(&Channel{}).Select("DISTINCT tag").Where("tag != ''").Offset(offset).Limit(limit).Find(&tags).Error
err := query.
Select("DISTINCT tag").
Where("tag is not null AND tag != ''").
Order(clause.OrderByColumn{Column: clause.Column{Name: "tag"}}).
Offset(offset).
Limit(limit).
Find(&tags).Error
return tags, err
}
@@ -860,24 +917,11 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str
baseQuery := DB.Model(&Channel{}).Omit("key")
// 构造WHERE子句
var whereClause string
var args []interface{}
if group != "" && group != "null" {
var groupCondition string
if common.UsingMySQL {
groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?`
} else {
// sqlite, PostgreSQL
groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?`
}
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%")
} else {
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%")
}
whereClause := "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
args := []any{common.String2Int(keyword), "%" + keyword + "%", keyword, "%" + keyword + "%", "%" + model + "%"}
baseQuery = ApplyChannelGroupFilter(baseQuery.Where(whereClause, args...), group)
subQuery := baseQuery.Where(whereClause, args...).
subQuery := baseQuery.
Select("tag").
Where("tag != ''").
Order(order)
@@ -1018,8 +1062,12 @@ func CountAllChannels() (int64, error) {
// CountAllTags returns number of non-empty distinct tags
func CountAllTags() (int64, error) {
return CountChannelTags(DB.Model(&Channel{}))
}
func CountChannelTags(query *gorm.DB) (int64, error) {
var total int64
err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
err := query.Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
return total, err
}
+8 -4
View File
@@ -11,6 +11,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/setting/ratio_setting"
)
@@ -257,9 +258,12 @@ func CacheUpdateChannel(channel *Channel) {
return
}
println("CacheUpdateChannel:", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex)
println("before:", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex)
if channelsIDM == nil {
channelsIDM = make(map[int]*Channel)
}
if oldChannel, ok := channelsIDM[channel.Id]; ok {
logger.LogDebug(nil, "CacheUpdateChannel before: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, oldChannel.ChannelInfo.MultiKeyPollingIndex)
}
channelsIDM[channel.Id] = channel
println("after :", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex)
logger.LogDebug(nil, "CacheUpdateChannel after: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex)
}
+49 -43
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
@@ -35,8 +36,9 @@ type Log struct {
TokenId int `json:"token_id" gorm:"default:0;index"`
Group string `json:"group" gorm:"index"`
Ip string `json:"ip" gorm:"index;default:''"`
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
Other string `json:"other"`
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"`
Other string `json:"other"`
}
// don't use iota, avoid change log type value
@@ -147,6 +149,7 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
username := c.GetString("username")
requestId := c.GetString(common.RequestIdKey)
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
otherStr := common.MapToJsonStr(other)
// 判断是否需要记录 IP
needRecordIp := false
@@ -177,8 +180,9 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
}
return ""
}(),
RequestId: requestId,
Other: otherStr,
RequestId: requestId,
UpstreamRequestId: upstreamRequestId,
Other: otherStr,
}
err := LOG_DB.Create(log).Error
if err != nil {
@@ -208,6 +212,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params)))
username := c.GetString("username")
requestId := c.GetString(common.RequestIdKey)
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
otherStr := common.MapToJsonStr(params.Other)
// 判断是否需要记录 IP
needRecordIp := false
@@ -238,8 +243,9 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
}
return ""
}(),
RequestId: requestId,
Other: otherStr,
RequestId: requestId,
UpstreamRequestId: upstreamRequestId,
Other: otherStr,
}
err := LOG_DB.Create(log).Error
if err != nil {
@@ -295,7 +301,7 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) {
}
}
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string) (logs []*Log, total int64, err error) {
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) {
var tx *gorm.DB
if logType == LogTypeUnknown {
tx = LOG_DB
@@ -303,18 +309,15 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
tx = LOG_DB.Where("logs.type = ?", logType)
}
if modelName != "" {
tx = tx.Where("logs.model_name like ?", modelName)
}
if username != "" {
tx = tx.Where("logs.username = ?", username)
}
if tokenName != "" {
tx = tx.Where("logs.token_name = ?", tokenName)
}
tx = applyLogContainsFilter(tx, "logs.model_name", modelName)
tx = applyLogContainsFilter(tx, "logs.username", username)
tx = applyLogContainsFilter(tx, "logs.token_name", tokenName)
if requestId != "" {
tx = tx.Where("logs.request_id = ?", requestId)
}
if upstreamRequestId != "" {
tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId)
}
if startTimestamp != 0 {
tx = tx.Where("logs.created_at >= ?", startTimestamp)
}
@@ -381,7 +384,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
const logSearchCountLimit = 10000
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string) (logs []*Log, total int64, err error) {
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) {
var tx *gorm.DB
if logType == LogTypeUnknown {
tx = LOG_DB.Where("logs.user_id = ?", userId)
@@ -389,19 +392,14 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType)
}
if modelName != "" {
modelNamePattern, err := sanitizeLikePattern(modelName)
if err != nil {
return nil, 0, err
}
tx = tx.Where("logs.model_name LIKE ? ESCAPE '!'", modelNamePattern)
}
if tokenName != "" {
tx = tx.Where("logs.token_name = ?", tokenName)
}
tx = applyLogContainsFilter(tx, "logs.model_name", modelName)
tx = applyLogContainsFilter(tx, "logs.token_name", tokenName)
if requestId != "" {
tx = tx.Where("logs.request_id = ?", requestId)
}
if upstreamRequestId != "" {
tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId)
}
if startTimestamp != 0 {
tx = tx.Where("logs.created_at >= ?", startTimestamp)
}
@@ -432,34 +430,42 @@ type Stat struct {
Tpm int `json:"tpm"`
}
func logContainsPattern(input string) (string, bool) {
input = strings.TrimSpace(input)
if input == "" {
return "", false
}
replacer := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_")
return "%" + replacer.Replace(input) + "%", true
}
func applyLogContainsFilter(tx *gorm.DB, column string, value string) *gorm.DB {
pattern, ok := logContainsPattern(value)
if !ok {
return tx
}
return tx.Where(column+" LIKE ? ESCAPE '!'", pattern)
}
func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string) (stat Stat, err error) {
tx := LOG_DB.Table("logs").Select("sum(quota) quota")
// 为rpm和tpm创建单独的查询
rpmTpmQuery := LOG_DB.Table("logs").Select("count(*) rpm, sum(prompt_tokens) + sum(completion_tokens) tpm")
if username != "" {
tx = tx.Where("username = ?", username)
rpmTpmQuery = rpmTpmQuery.Where("username = ?", username)
}
if tokenName != "" {
tx = tx.Where("token_name = ?", tokenName)
rpmTpmQuery = rpmTpmQuery.Where("token_name = ?", tokenName)
}
tx = applyLogContainsFilter(tx, "username", username)
rpmTpmQuery = applyLogContainsFilter(rpmTpmQuery, "username", username)
tx = applyLogContainsFilter(tx, "token_name", tokenName)
rpmTpmQuery = applyLogContainsFilter(rpmTpmQuery, "token_name", tokenName)
if startTimestamp != 0 {
tx = tx.Where("created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("created_at <= ?", endTimestamp)
}
if modelName != "" {
modelNamePattern, err := sanitizeLikePattern(modelName)
if err != nil {
return stat, err
}
tx = tx.Where("model_name LIKE ? ESCAPE '!'", modelNamePattern)
rpmTpmQuery = rpmTpmQuery.Where("model_name LIKE ? ESCAPE '!'", modelNamePattern)
}
tx = applyLogContainsFilter(tx, "model_name", modelName)
rpmTpmQuery = applyLogContainsFilter(rpmTpmQuery, "model_name", modelName)
if channel != 0 {
tx = tx.Where("channel_id = ?", channel)
rpmTpmQuery = rpmTpmQuery.Where("channel_id = ?", channel)
+17 -10
View File
@@ -37,13 +37,13 @@ func UpsertPerfMetric(metric *PerfMetric) error {
{Name: "bucket_ts"},
},
DoUpdates: clause.Assignments(map[string]interface{}{
"request_count": gorm.Expr("request_count + ?", metric.RequestCount),
"success_count": gorm.Expr("success_count + ?", metric.SuccessCount),
"total_latency_ms": gorm.Expr("total_latency_ms + ?", metric.TotalLatencyMs),
"ttft_sum_ms": gorm.Expr("ttft_sum_ms + ?", metric.TtftSumMs),
"ttft_count": gorm.Expr("ttft_count + ?", metric.TtftCount),
"output_tokens": gorm.Expr("output_tokens + ?", metric.OutputTokens),
"generation_ms": gorm.Expr("generation_ms + ?", metric.GenerationMs),
"request_count": gorm.Expr("perf_metrics.request_count + ?", metric.RequestCount),
"success_count": gorm.Expr("perf_metrics.success_count + ?", metric.SuccessCount),
"total_latency_ms": gorm.Expr("perf_metrics.total_latency_ms + ?", metric.TotalLatencyMs),
"ttft_sum_ms": gorm.Expr("perf_metrics.ttft_sum_ms + ?", metric.TtftSumMs),
"ttft_count": gorm.Expr("perf_metrics.ttft_count + ?", metric.TtftCount),
"output_tokens": gorm.Expr("perf_metrics.output_tokens + ?", metric.OutputTokens),
"generation_ms": gorm.Expr("perf_metrics.generation_ms + ?", metric.GenerationMs),
}),
}).Create(metric).Error
}
@@ -68,11 +68,18 @@ type PerfMetricSummary struct {
GenerationMs int64 `json:"generation_ms"`
}
func GetPerfMetricsSummaryAll(startTs int64, endTs int64) ([]PerfMetricSummary, error) {
func GetPerfMetricsSummaryAll(startTs int64, endTs int64, groups []string) ([]PerfMetricSummary, error) {
var summaries []PerfMetricSummary
err := DB.Model(&PerfMetric{}).
query := DB.Model(&PerfMetric{}).
Select("model_name, SUM(request_count) as request_count, SUM(success_count) as success_count, SUM(total_latency_ms) as total_latency_ms, SUM(output_tokens) as output_tokens, SUM(generation_ms) as generation_ms").
Where("bucket_ts >= ? AND bucket_ts <= ?", startTs, endTs).
Where("bucket_ts >= ? AND bucket_ts <= ?", startTs, endTs)
if groups != nil {
if len(groups) == 0 {
return summaries, nil
}
query = query.Where(commonGroupCol+" IN ?", groups)
}
err := query.
Group("model_name").
Having("SUM(request_count) > 0").
Find(&summaries).Error
+3
View File
@@ -26,6 +26,7 @@ func TestMain(m *testing.M) {
common.RedisEnabled = false
common.BatchUpdateEnabled = false
common.LogConsumeEnabled = true
initCol()
sqlDB, err := db.DB()
if err != nil {
@@ -43,6 +44,7 @@ func TestMain(m *testing.M) {
&SubscriptionPlan{},
&SubscriptionOrder{},
&UserSubscription{},
&PerfMetric{},
); err != nil {
panic("failed to migrate: " + err.Error())
}
@@ -62,6 +64,7 @@ func truncateTables(t *testing.T) {
DB.Exec("DELETE FROM subscription_orders")
DB.Exec("DELETE FROM subscription_plans")
DB.Exec("DELETE FROM user_subscriptions")
DB.Exec("DELETE FROM perf_metrics")
})
}
+5 -4
View File
@@ -11,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/bytedance/gopkg/util/gopool"
"gorm.io/gorm"
@@ -34,8 +35,8 @@ type User struct {
OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"`
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"`
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
AccessToken *string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
Quota int `json:"quota" gorm:"type:int;default:0"`
UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota
RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number
@@ -420,7 +421,7 @@ func (user *User) Insert(inviterId int) error {
if common.QuotaForNewUser > 0 {
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser)))
}
if inviterId != 0 {
if inviterId != 0 && operation_setting.IsPaymentComplianceConfirmed() {
if common.QuotaForInvitee > 0 {
_ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true)
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee)))
@@ -481,7 +482,7 @@ func (user *User) FinalizeOAuthUserCreation(inviterId int) {
if common.QuotaForNewUser > 0 {
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser)))
}
if inviterId != 0 {
if inviterId != 0 && operation_setting.IsPaymentComplianceConfirmed() {
if common.QuotaForInvitee > 0 {
_ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true)
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee)))
+20 -3
View File
@@ -122,7 +122,7 @@ func Query(params QueryParams) (QueryResult, error) {
return buildQueryResult(params.Model, merged), nil
}
func QuerySummaryAll(hours int) (SummaryAllResult, error) {
func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
if hours <= 0 {
hours = 24
}
@@ -131,8 +131,9 @@ func QuerySummaryAll(hours int) (SummaryAllResult, error) {
}
endTs := time.Now().Unix()
startTs := endTs - int64(hours)*3600
allowedGroups := allowedGroupSet(groups)
rows, err := model.GetPerfMetricsSummaryAll(startTs, endTs)
rows, err := model.GetPerfMetricsSummaryAll(startTs, endTs, groups)
if err != nil {
return SummaryAllResult{}, err
}
@@ -153,6 +154,11 @@ func QuerySummaryAll(hours int) (SummaryAllResult, error) {
if k.bucketTs < startTs || k.bucketTs > endTs {
return true
}
if allowedGroups != nil {
if _, ok := allowedGroups[k.group]; !ok {
return true
}
}
snap := value.(*atomicBucket).snapshot()
if snap.requestCount == 0 {
return true
@@ -187,12 +193,23 @@ func QuerySummaryAll(hours int) (SummaryAllResult, error) {
})
}
sort.Slice(models, func(i, j int) bool {
return models[i].ModelName < models[j].ModelName
return models[i].RequestCount > models[j].RequestCount
})
return SummaryAllResult{Models: models}, nil
}
func allowedGroupSet(groups []string) map[string]struct{} {
if groups == nil {
return nil
}
allowed := make(map[string]struct{}, len(groups))
for _, group := range groups {
allowed[group] = struct{}{}
}
return allowed
}
func bucketStart(ts int64) int64 {
bucketSeconds := perf_metrics_setting.GetBucketSeconds()
if bucketSeconds <= 0 {
+1 -1
View File
@@ -52,7 +52,7 @@ type ModelSummary struct {
AvgLatencyMs int64 `json:"avg_latency_ms"`
SuccessRate float64 `json:"success_rate"`
AvgTps float64 `json:"avg_tps"`
RequestCount int64 `json:"request_count"`
RequestCount int64 `json:"-"`
}
type SummaryAllResult struct {
+3 -4
View File
@@ -229,7 +229,7 @@ func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) (
time.Sleep(time.Duration(5) * time.Second)
for {
logger.LogDebug(c, fmt.Sprintf("asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds))
logger.LogDebug(c, "asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds)
step++
rsp, err, body := updateTask(info, taskID)
responseBody = body
@@ -320,11 +320,10 @@ func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *rela
}
}
//logger.LogDebug(c, "ali_async_task_result: "+string(originRespBody))
if a.IsSyncImageModel {
logger.LogDebug(c, "ali_sync_image_result: "+string(originRespBody))
logger.LogDebug(c, "ali_sync_image_result: %s", originRespBody)
} else {
logger.LogDebug(c, "ali_async_image_result: "+string(originRespBody))
logger.LogDebug(c, "ali_async_image_result: %s", originRespBody)
}
imageResponses := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat)
+14 -30
View File
@@ -292,9 +292,7 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
if err != nil {
return nil, fmt.Errorf("get request url failed: %w", err)
}
if common2.DebugEnabled {
println("fullRequestURL:", fullRequestURL)
}
logger.LogDebug(c, "fullRequestURL: %s", fullRequestURL)
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
if err != nil {
return nil, fmt.Errorf("new request failed: %w", err)
@@ -323,9 +321,7 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
if err != nil {
return nil, fmt.Errorf("get request url failed: %w", err)
}
if common2.DebugEnabled {
println("fullRequestURL:", fullRequestURL)
}
logger.LogDebug(c, "fullRequestURL: %s", fullRequestURL)
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
if err != nil {
return nil, fmt.Errorf("new request failed: %w", err)
@@ -388,13 +384,9 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
defer func() {
// 增加panic恢复处理
if r := recover(); r != nil {
if common2.DebugEnabled {
println("SSE ping goroutine panic recovered:", fmt.Sprintf("%v", r))
}
}
if common2.DebugEnabled {
println("SSE ping goroutine stopped.")
logger.LogDebug(c, "SSE ping goroutine panic recovered: %v", r)
}
logger.LogDebug(c, "SSE ping goroutine stopped")
}()
if pingInterval <= 0 {
@@ -405,15 +397,11 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
// 确保在任何情况下都清理ticker
defer func() {
ticker.Stop()
if common2.DebugEnabled {
println("SSE ping ticker stopped")
}
logger.LogDebug(c, "SSE ping ticker stopped")
}()
var pingMutex sync.Mutex
if common2.DebugEnabled {
println("SSE ping goroutine started")
}
logger.LogDebug(c, "SSE ping goroutine started")
// 增加超时控制,防止goroutine长时间运行
maxPingDuration := 120 * time.Minute // 最大ping持续时间
@@ -425,9 +413,7 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
// 发送 ping 数据
case <-ticker.C:
if err := sendPingData(c, &pingMutex); err != nil {
if common2.DebugEnabled {
println("SSE ping error, stopping goroutine:", err.Error())
}
logger.LogDebug(c, "SSE ping error, stopping goroutine: %s", err.Error())
return
}
// 收到退出信号
@@ -438,9 +424,7 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
return
// 超时保护,防止goroutine无限运行
case <-pingTimeout.C:
if common2.DebugEnabled {
println("SSE ping goroutine timeout, stopping")
}
logger.LogDebug(c, "SSE ping goroutine timeout, stopping")
return
}
}
@@ -463,9 +447,7 @@ func sendPingData(c *gin.Context, mutex *sync.Mutex) error {
return
}
if common2.DebugEnabled {
println("SSE ping data sent.")
}
logger.LogDebug(c, "SSE ping data sent")
done <- nil
}()
@@ -507,9 +489,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
defer func() {
if stopPinger != nil {
stopPinger()
if common2.DebugEnabled {
println("SSE ping goroutine stopped by defer")
}
logger.LogDebug(c, "SSE ping goroutine stopped by defer")
}
}()
}
@@ -524,6 +504,10 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
return nil, errors.New("resp is nil")
}
if upID := resp.Header.Get(common2.RequestIdKey); upID != "" {
c.Set(common2.UpstreamRequestIdKey, upID)
}
_ = req.Body.Close()
_ = c.Request.Body.Close()
return resp, nil
+1 -3
View File
@@ -949,9 +949,7 @@ func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
if common.DebugEnabled {
println("responseBody: ", string(responseBody))
}
logger.LogDebug(c, "responseBody: %s", responseBody)
handleErr := HandleClaudeResponseData(c, info, claudeInfo, resp, responseBody)
if handleErr != nil {
return nil, handleErr
+2 -6
View File
@@ -26,9 +26,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if common.DebugEnabled {
println(string(responseBody))
}
logger.LogDebug(c, "Gemini native response body: %s", responseBody)
// 解析为 Gemini 原生响应格式
var geminiResponse dto.GeminiChatResponse
@@ -57,9 +55,7 @@ func NativeGeminiEmbeddingHandler(c *gin.Context, resp *http.Response, info *rel
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if common.DebugEnabled {
println(string(responseBody))
}
logger.LogDebug(c, "Gemini native embedding response body: %s", responseBody)
usage := service.ResponseText2Usage(c, "", info.UpstreamModelName, info.GetEstimatePromptTokens())
+2 -4
View File
@@ -1362,7 +1362,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
}
}
logger.LogDebug(c, fmt.Sprintf("info.SendResponseCount = %d", info.SendResponseCount))
logger.LogDebug(c, "info.SendResponseCount = %d", info.SendResponseCount)
if info.SendResponseCount == 0 {
// send first response
emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)
@@ -1422,9 +1422,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
service.CloseResponseBodyGracefully(resp)
if common.DebugEnabled {
println(string(responseBody))
}
logger.LogDebug(c, "Gemini response body: %s", responseBody)
var geminiResponse dto.GeminiChatResponse
err = common.Unmarshal(responseBody, &geminiResponse)
if err != nil {
+4
View File
@@ -11,6 +11,7 @@ import (
"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"
)
@@ -184,6 +185,9 @@ func handleChatCompletionResponse(c *gin.Context, resp *http.Response, info *rel
// Set response headers
for key, values := range resp.Header {
if !service.ShouldCopyUpstreamHeader(c, key, values) {
continue
}
for _, value := range values {
c.Header(key, value)
}
+5 -5
View File
@@ -377,7 +377,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
}
// 打印类似 curl 命令格式的信息
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form 'model=\"%s\"'", request.Model))
logger.LogDebug(c.Request.Context(), "--form 'model=\"%s\"'", request.Model)
// 遍历表单字段并打印输出
for key, values := range formData.Value {
@@ -386,7 +386,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
}
for _, value := range values {
writer.WriteField(key, value)
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form '%s=\"%s\"'", key, value))
logger.LogDebug(c.Request.Context(), "--form '%s=\"%s\"'", key, value)
}
}
@@ -398,8 +398,8 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
// 使用 formData 中的第一个文件
fileHeader := fileHeaders[0]
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form 'file=@\"%s\"' (size: %d bytes, content-type: %s)",
fileHeader.Filename, fileHeader.Size, fileHeader.Header.Get("Content-Type")))
logger.LogDebug(c.Request.Context(), "--form 'file=@\"%s\"' (size: %d bytes, content-type: %s)",
fileHeader.Filename, fileHeader.Size, fileHeader.Header.Get("Content-Type"))
file, err := fileHeader.Open()
if err != nil {
@@ -418,7 +418,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
// 关闭 multipart 编写器以设置分界线
writer.Close()
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--header 'Content-Type: %s'", writer.FormDataContentType()))
logger.LogDebug(c.Request.Context(), "--header 'Content-Type: %s'", writer.FormDataContentType())
return &requestBody, nil
}
}
+3
View File
@@ -30,6 +30,9 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
usage.PromptTokens = info.GetEstimatePromptTokens()
usage.TotalTokens = info.GetEstimatePromptTokens()
for k, v := range resp.Header {
if !service.ShouldCopyUpstreamHeader(c, k, v) {
continue
}
c.Writer.Header().Set(k, v[0])
}
c.Writer.WriteHeader(resp.StatusCode)
+3 -5
View File
@@ -155,9 +155,9 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
containStreamUsage = true
if common.DebugEnabled {
logger.LogDebug(c, fmt.Sprintf("Audio model usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
logger.LogDebug(c, "Audio model usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens,
usage.InputTokens, usage.OutputTokens))
usage.InputTokens, usage.OutputTokens)
}
}
}
@@ -200,9 +200,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
if common.DebugEnabled {
println("upstream response body:", string(responseBody))
}
logger.LogDebug(c, "upstream response body: %s", responseBody)
// Unmarshal to simpleResponse
if info.ChannelType == constant.ChannelTypeOpenRouter && info.ChannelOtherSettings.IsOpenRouterEnterprise() {
// 尝试解析为 openrouter enterprise
+2 -4
View File
@@ -11,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
@@ -177,9 +178,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
}
if common.DebugEnabled {
println("requestBody: ", string(jsonData))
}
logger.LogDebug(c, "requestBody: %s", jsonData)
requestBody = bytes.NewBuffer(jsonData)
}
@@ -202,7 +201,6 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
usage, newAPIError := adaptor.DoResponse(c, httpResp, info)
//log.Printf("usage: %v", usage)
if newAPIError != nil {
// reset status code 重置状态码
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
+22 -10
View File
@@ -26,13 +26,20 @@ const (
var errSourceHeaderNotFound = errors.New("source header does not exist")
var paramOverrideKeyAuditPaths = map[string]struct{}{
"model": {},
"original_model": {},
"upstream_model": {},
"service_tier": {},
"inference_geo": {},
"speed": {},
var paramOverrideSensitivePathPrefixes = []string{
"model",
"original_model",
"upstream_model",
"service_tier",
"inference_geo",
"speed",
"messages",
"input",
"instructions",
"system",
"contents",
"systemInstruction",
"system_instruction",
}
type paramOverrideAuditRecorder struct {
@@ -206,6 +213,7 @@ func shouldEnableParamOverrideAudit(paramOverride map[string]interface{}) bool {
if operations, ok := tryParseOperations(paramOverride); ok {
for _, operation := range operations {
if shouldAuditParamPath(strings.TrimSpace(operation.Path)) ||
shouldAuditParamPath(strings.TrimSpace(operation.From)) ||
shouldAuditParamPath(strings.TrimSpace(operation.To)) {
return true
}
@@ -255,15 +263,19 @@ func shouldAuditParamPath(path string) bool {
if common.DebugEnabled {
return true
}
_, ok := paramOverrideKeyAuditPaths[path]
return ok
for _, prefix := range paramOverrideSensitivePathPrefixes {
if path == prefix || strings.HasPrefix(path, prefix+".") {
return true
}
}
return false
}
func shouldAuditOperation(mode, path, from, to string) bool {
if common.DebugEnabled {
return true
}
for _, candidate := range []string{path, to} {
for _, candidate := range []string{path, from, to} {
if shouldAuditParamPath(candidate) {
return true
}
+90
View File
@@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/samber/lo"
"github.com/stretchr/testify/require"
)
func TestApplyParamOverrideTrimPrefix(t *testing.T) {
@@ -2184,6 +2185,95 @@ func TestApplyParamOverrideWithRelayInfoRecordsOnlyKeyOperationsWhenDebugDisable
}
}
func TestApplyParamOverrideWithRelayInfoRecordsConversationBodyOperationsWhenDebugDisabled(t *testing.T) {
originalDebugEnabled := common2.DebugEnabled
common2.DebugEnabled = false
t.Cleanup(func() {
common2.DebugEnabled = originalDebugEnabled
})
info := &RelayInfo{
ChannelMeta: &ChannelMeta{
ParamOverride: map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"mode": "replace",
"path": "messages.0.content",
"from": "hello",
"to": "hi",
},
map[string]interface{}{
"mode": "set",
"path": "input.0.content.0.text",
"value": "rewritten response input",
},
map[string]interface{}{
"mode": "set",
"path": "instructions",
"value": "new instruction",
},
map[string]interface{}{
"mode": "append",
"path": "contents.0.parts",
"value": map[string]interface{}{"text": "new gemini part"},
},
map[string]interface{}{
"mode": "copy",
"from": "system",
"to": "metadata.system_copy",
},
map[string]interface{}{
"mode": "set",
"path": "temperature",
"value": 0.1,
},
},
},
},
}
out, err := ApplyParamOverrideWithRelayInfo([]byte(`{
"messages":[{"role":"user","content":"hello world"}],
"input":[{"role":"user","content":[{"type":"input_text","text":"original response input"}]}],
"instructions":"old instruction",
"system":"old system",
"contents":[{"role":"user","parts":[{"text":"hello gemini"}]}],
"temperature":0.7
}`), info)
require.NoError(t, err)
assertJSONEqual(t, `{
"messages":[{"role":"user","content":"hi world"}],
"input":[{"role":"user","content":[{"type":"input_text","text":"rewritten response input"}]}],
"instructions":"new instruction",
"system":"old system",
"contents":[{"role":"user","parts":[{"text":"hello gemini"},{"text":"new gemini part"}]}],
"temperature":0.1,
"metadata":{"system_copy":"old system"}
}`, string(out))
require.Equal(t, []string{
"replace messages.0.content from hello to hi",
"set input.0.content.0.text = rewritten response input",
"set instructions = new instruction",
"append contents.0.parts with {\"text\":\"new gemini part\"}",
"copy system -> metadata.system_copy",
}, info.ParamOverrideAudit)
}
func TestShouldAuditParamPathUsesFieldBoundaryPrefixMatching(t *testing.T) {
originalDebugEnabled := common2.DebugEnabled
common2.DebugEnabled = false
t.Cleanup(func() {
common2.DebugEnabled = originalDebugEnabled
})
require.True(t, shouldAuditParamPath("messages"))
require.True(t, shouldAuditParamPath("messages.0.content"))
require.True(t, shouldAuditParamPath("systemInstruction.parts.0.text"))
require.False(t, shouldAuditParamPath("model_name"))
require.False(t, shouldAuditParamPath("message"))
}
func assertJSONEqual(t *testing.T, want, got string) {
t.Helper()
+2 -3
View File
@@ -7,6 +7,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relay/channel/xinference"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
@@ -21,9 +22,7 @@ func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
service.CloseResponseBodyGracefully(resp)
if common.DebugEnabled {
println("reranker response body: ", string(responseBody))
}
logger.LogDebug(c, "reranker response body: %s", responseBody)
var jinaResp dto.RerankResponse
if info.ChannelType == constant.ChannelTypeXinference {
var xinRerankResponse xinference.XinRerankResponse
+2 -2
View File
@@ -102,7 +102,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
}
if common.DebugEnabled {
if debugBytes, bErr := storage.Bytes(); bErr == nil {
println("requestBody: ", string(debugBytes))
logger.LogDebug(c, "requestBody: %s", debugBytes)
}
}
requestBody = common.ReaderOnly(storage)
@@ -174,7 +174,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
}
}
logger.LogDebug(c, fmt.Sprintf("text request body: %s", string(jsonData)))
logger.LogDebug(c, "text request body: %s", jsonData)
requestBody = bytes.NewBuffer(jsonData)
}
+1 -1
View File
@@ -58,7 +58,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
}
}
logger.LogDebug(c, fmt.Sprintf("converted embedding request body: %s", string(jsonData)))
logger.LogDebug(c, "converted embedding request body: %s", jsonData)
var requestBody io.Reader = bytes.NewBuffer(jsonData)
statusCodeMappingStr := c.GetString("status_code_mapping")
resp, err := adaptor.DoRequest(c, info, requestBody)
+2 -2
View File
@@ -163,7 +163,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
}
logger.LogDebug(c, "Gemini request body: "+string(jsonData))
logger.LogDebug(c, "Gemini request body: %s", jsonData)
requestBody = bytes.NewReader(jsonData)
}
@@ -262,7 +262,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
return newAPIErrorFromParamOverride(err)
}
}
logger.LogDebug(c, "Gemini embedding request body: "+string(jsonData))
logger.LogDebug(c, "Gemini embedding request body: %s", jsonData)
requestBody = bytes.NewReader(jsonData)
resp, err := adaptor.DoRequest(c, info, requestBody)
+3 -5
View File
@@ -45,7 +45,7 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) types.
// check auto group
autoGroup, exists := ctx.Get("auto_group")
if exists {
logger.LogDebug(ctx, fmt.Sprintf("final group: %s", autoGroup))
logger.LogDebug(ctx, "final group: %s", autoGroup)
relayInfo.UsingGroup = autoGroup.(string)
}
@@ -157,7 +157,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
}
if common.DebugEnabled {
println(fmt.Sprintf("model_price_helper result: %s", priceData.ToSetting()))
logger.LogDebug(c, "model_price_helper result: %s", priceData.ToSetting())
}
info.PriceData = priceData
return priceData, nil
@@ -299,9 +299,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT
QuotaToPreConsume: preConsumedQuota,
}
if common.DebugEnabled {
println(fmt.Sprintf("model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", info.OriginModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier))
}
logger.LogDebug(c, "model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", info.OriginModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier)
info.PriceData = priceData
return priceData, nil
+10 -23
View File
@@ -72,14 +72,11 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
pingTicker = time.NewTicker(pingInterval)
}
if common.DebugEnabled {
// print timeout and ping interval for debugging
println("relay timeout seconds:", common.RelayTimeout)
println("relay max idle conns:", common.RelayMaxIdleConns)
println("relay max idle conns per host:", common.RelayMaxIdleConnsPerHost)
println("streaming timeout seconds:", int64(streamingTimeout.Seconds()))
println("ping interval seconds:", int64(pingInterval.Seconds()))
}
logger.LogDebug(c, "relay timeout seconds: %d", common.RelayTimeout)
logger.LogDebug(c, "relay max idle conns: %d", common.RelayMaxIdleConns)
logger.LogDebug(c, "relay max idle conns per host: %d", common.RelayMaxIdleConnsPerHost)
logger.LogDebug(c, "streaming timeout seconds: %d", int64(streamingTimeout.Seconds()))
logger.LogDebug(c, "ping interval seconds: %d", int64(pingInterval.Seconds()))
// 改进资源清理,确保所有 goroutine 正确退出
defer func() {
@@ -127,9 +124,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("ping panic: %v", r))
common.SafeSendBool(stopChan, true)
}
if common.DebugEnabled {
println("ping goroutine exited")
}
logger.LogDebug(c, "ping goroutine exited")
}()
// 添加超时保护,防止 goroutine 无限运行
@@ -155,9 +150,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPingFail, err)
return
}
if common.DebugEnabled {
println("ping data sent")
}
logger.LogDebug(c, "ping data sent")
case <-time.After(10 * time.Second):
logger.LogError(c, "ping data send timeout")
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPingFail, fmt.Errorf("ping send timeout"))
@@ -217,9 +210,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("scanner panic: %v", r))
}
common.SafeSendBool(stopChan, true)
if common.DebugEnabled {
println("scanner goroutine exited")
}
logger.LogDebug(c, "scanner goroutine exited")
}()
for scanner.Scan() {
@@ -237,9 +228,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
ticker.Reset(streamingTimeout)
data := scanner.Text()
if common.DebugEnabled {
println(data)
}
logger.LogDebug(c, "stream scanner data: %s", data)
if len(data) < 6 {
continue
@@ -265,9 +254,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
}
} else {
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonDone, nil)
if common.DebugEnabled {
println("received [DONE], stopping scanner")
}
logger.LogDebug(c, "received [DONE], stopping scanner")
return
}
}
+1 -3
View File
@@ -76,9 +76,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
}
}
if common.DebugEnabled {
logger.LogDebug(c, fmt.Sprintf("image request body: %s", string(jsonData)))
}
logger.LogDebug(c, "image request body: %s", jsonData)
requestBody = bytes.NewBuffer(jsonData)
}
}
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
@@ -473,7 +474,7 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt
c.Set("base_url", channel.GetBaseURL())
c.Set("channel_id", originTask.ChannelId)
c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key))
log.Printf("检测到此操作为放大、变换、重绘,获取原channel信息: %s,%s", strconv.Itoa(originTask.ChannelId), channel.GetBaseURL())
logger.LogDebug(c, "Midjourney action uses origin channel: id=%s, base_url=%s", strconv.Itoa(originTask.ChannelId), channel.GetBaseURL())
}
midjRequest.Prompt = originTask.Prompt
+2 -3
View File
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
@@ -67,9 +68,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
}
if common.DebugEnabled {
println(fmt.Sprintf("Rerank request body: %s", string(jsonData)))
}
logger.LogDebug(c, "Rerank request body: %s", jsonData)
requestBody = bytes.NewBuffer(jsonData)
}
+2 -3
View File
@@ -10,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/common"
appconstant "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper"
@@ -102,9 +103,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
}
}
if common.DebugEnabled {
println("requestBody: ", string(jsonData))
}
logger.LogDebug(c, "requestBody: %s", jsonData)
requestBody = bytes.NewBuffer(jsonData)
}
+4 -3
View File
@@ -30,14 +30,14 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/about", controller.GetAbout)
//apiRouter.GET("/midjourney", controller.GetMidjourney)
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing)
apiRouter.GET("/pricing", middleware.HeaderNavModuleAuth("pricing"), controller.GetPricing)
perfMetricsRoute := apiRouter.Group("/perf-metrics")
perfMetricsRoute.Use(middleware.TryUserAuth())
perfMetricsRoute.Use(middleware.HeaderNavModulePublicOrUserAuth("pricing"))
{
perfMetricsRoute.GET("/summary", controller.GetPerfMetricsSummary)
perfMetricsRoute.GET("", controller.GetPerfMetrics)
}
apiRouter.GET("/rankings", controller.GetRankings)
apiRouter.GET("/rankings", middleware.HeaderNavModuleAuth("rankings"), controller.GetRankings)
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
@@ -181,6 +181,7 @@ func SetApiRouter(router *gin.Engine) {
{
optionRoute.GET("/", controller.GetOptions)
optionRoute.PUT("/", controller.UpdateOption)
optionRoute.POST("/payment_compliance", controller.ConfirmPaymentCompliance)
optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats)
optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache)
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
+5
View File
@@ -302,6 +302,11 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
return ""
}
return strings.TrimSpace(c.GetString(src.Key))
case "request_header":
if c == nil || c.Request == nil || src.Key == "" {
return ""
}
return strings.TrimSpace(c.Request.Header.Get(src.Key))
case "gjson":
if src.Path == "" {
return ""
+60
View File
@@ -176,6 +176,66 @@ func TestShouldSkipRetryAfterChannelAffinityFailure(t *testing.T) {
}
}
func TestExtractChannelAffinityValue_RequestHeader(t *testing.T) {
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
ctx.Request.Header.Set("X-Affinity-Key", " tenant-123 ")
value := extractChannelAffinityValue(ctx, operation_setting.ChannelAffinityKeySource{
Type: "request_header",
Key: "X-Affinity-Key",
})
require.Equal(t, "tenant-123", value)
}
func TestGetPreferredChannelByAffinity_RequestHeaderKeySource(t *testing.T) {
gin.SetMode(gin.TestMode)
rule := operation_setting.ChannelAffinityRule{
Name: "header-affinity",
ModelRegex: []string{"^gpt-.*$"},
PathRegex: []string{"/v1/responses"},
KeySources: []operation_setting.ChannelAffinityKeySource{
{Type: "request_header", Key: "X-Affinity-Key"},
},
IncludeRuleName: true,
IncludeModelName: true,
}
affinityValue := fmt.Sprintf("header-hit-%d", time.Now().UnixNano())
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, "gpt-5", "default", affinityValue)
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9528, time.Minute))
t.Cleanup(func() {
_, _ = cache.DeleteMany([]string{cacheKeySuffix})
})
setting := operation_setting.GetChannelAffinitySetting()
originalRules := setting.Rules
setting.Rules = append([]operation_setting.ChannelAffinityRule{rule}, originalRules...)
t.Cleanup(func() {
setting.Rules = originalRules
})
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
ctx.Request.Header.Set("X-Affinity-Key", affinityValue)
channelID, found := GetPreferredChannelByAffinity(ctx, "gpt-5", "default")
require.True(t, found)
require.Equal(t, 9528, channelID)
meta, ok := getChannelAffinityMeta(ctx)
require.True(t, ok)
require.Equal(t, "request_header", meta.KeySourceType)
require.Equal(t, "X-Affinity-Key", meta.KeySourceKey)
require.Equal(t, buildChannelAffinityKeyHint(affinityValue), meta.KeyHint)
}
func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
gin.SetMode(gin.TestMode)
+1 -1
View File
@@ -85,7 +85,7 @@ func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, e
var readData []byte
limits := []int{512, 8 * 1024, 24 * 1024, 64 * 1024}
for _, limit := range limits {
logger.LogDebug(c, fmt.Sprintf("Trying to read %d bytes to determine file type", limit))
logger.LogDebug(c, "Trying to read %d bytes to determine file type", limit)
if len(readData) < limit {
need := limit - len(readData)
tmp := make([]byte, need)
+2 -2
View File
@@ -50,7 +50,7 @@ func LoadFileSource(c *gin.Context, source types.FileSource, reason ...string) (
}
if common.DebugEnabled {
logger.LogDebug(c, fmt.Sprintf("LoadFileSource starting for: %s", source.GetIdentifier()))
logger.LogDebug(c, "LoadFileSource starting for: %s", source.GetIdentifier())
}
// 1. 快速检查内部缓存
@@ -208,7 +208,7 @@ func loadFromURL(c *gin.Context, url string, reason ...string) (*types.CachedFil
}
common.IncrementDiskFiles(base64Size)
if common.DebugEnabled {
logger.LogDebug(c, fmt.Sprintf("File cached to disk: %s, size: %d bytes", diskPath, base64Size))
logger.LogDebug(c, "File cached to disk: %s, size: %d bytes", diskPath, base64Size)
}
}
} else {
+20 -2
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
@@ -22,6 +23,24 @@ func CloseResponseBodyGracefully(httpResponse *http.Response) {
}
}
// ShouldCopyUpstreamHeader checks whether a given upstream response header
// should be copied to the client response. It returns false for Content-Length
// (managed separately) and X-Oneapi-Request-Id (to preserve the local instance
// ID). When the upstream header is X-Oneapi-Request-Id, the value is captured
// into the Gin context for later logging.
func ShouldCopyUpstreamHeader(c *gin.Context, k string, v []string) bool {
if strings.EqualFold(k, "Content-Length") {
return false
}
if strings.EqualFold(k, common.RequestIdKey) {
if c != nil && len(v) > 0 {
c.Set(common.UpstreamRequestIdKey, v[0])
}
return false
}
return true
}
func IOCopyBytesGracefully(c *gin.Context, src *http.Response, data []byte) {
if c.Writer == nil {
return
@@ -35,8 +54,7 @@ func IOCopyBytesGracefully(c *gin.Context, src *http.Response, data []byte) {
// For example, Postman will report error, and we cannot check the response at all.
if src != nil {
for k, v := range src.Header {
// avoid setting Content-Length
if k == "Content-Length" {
if !ShouldCopyUpstreamHeader(c, k, v) {
continue
}
c.Writer.Header().Set(k, v[0])
+3 -5
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"strings"
@@ -13,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/setting"
@@ -235,9 +235,8 @@ func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestU
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "read_response_body_failed", statusCode), nullBytes, err
}
CloseResponseBodyGracefully(resp)
respStr := string(responseBody)
log.Printf("respStr: %s", respStr)
if respStr == "" {
logger.LogDebug(c, "midjourney response body: %s", responseBody)
if len(responseBody) == 0 {
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "empty_response_body", statusCode), responseBody, nil
} else {
err = json.Unmarshal(responseBody, &midjResponse)
@@ -248,7 +247,6 @@ func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestU
}
}
}
//log.Printf("midjResponse: %v", midjResponse)
//for k, v := range resp.Header {
// c.Writer.Header().Set(k, v[0])
//}
+3 -5
View File
@@ -3,7 +3,6 @@ package service
import (
"errors"
"fmt"
"log"
"math"
"strings"
"time"
@@ -17,7 +16,6 @@ import (
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/QuantumNous/new-api/types"
"github.com/bytedance/gopkg/util/gopool"
@@ -113,7 +111,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
autoGroup, exists := common.GetContextKey(ctx, constant.ContextKeyAutoGroup)
if exists {
groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string))
log.Printf("final group ratio: %f", groupRatio)
logger.LogDebug(ctx, "final group ratio: %f", groupRatio)
relayInfo.UsingGroup = autoGroup.(string)
}
@@ -467,7 +465,7 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
}
if quotaTooLow {
prompt := "您的额度即将用尽"
topUpLink := fmt.Sprintf("%s/console/topup", system_setting.ServerAddress)
topUpLink := PaymentReturnURL("/console/topup")
// 根据通知方式生成不同的内容格式
var content string
@@ -521,7 +519,7 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) {
}
prompt := "您的订阅额度即将用尽"
topUpLink := fmt.Sprintf("%s/console/topup", system_setting.ServerAddress)
topUpLink := PaymentReturnURL("/console/topup")
var content string
var values []interface{}
+13
View File
@@ -0,0 +1,13 @@
package service
import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
)
func PaymentReturnURL(suffix string) string {
base := strings.TrimRight(system_setting.ServerAddress, "/")
return base + common.ThemeAwarePath(suffix)
}
+4 -4
View File
@@ -372,7 +372,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
}
logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask response: %s", string(responseBody)))
logger.LogDebug(ctx, "updateVideoSingleTask response: %s", responseBody)
snap := task.Snapshot()
@@ -380,7 +380,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
// try parse as New API response format
var responseItems dto.TaskResponse[model.Task]
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask parsed as new api response format: %+v", responseItems))
logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: %+v", responseItems)
t := responseItems.Data
taskResult.TaskID = t.TaskID
taskResult.Status = string(t.Status)
@@ -394,7 +394,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
task.Data = redactVideoResponseBody(responseBody)
logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask taskResult: %+v", taskResult))
logger.LogDebug(ctx, "updateVideoSingleTask taskResult: %+v", taskResult)
now := time.Now().Unix()
if taskResult.Status == "" {
@@ -488,7 +488,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
}
} else {
// No changes, skip update
logger.LogDebug(ctx, fmt.Sprintf("No update needed for task %s", task.TaskID))
logger.LogDebug(ctx, "No update needed for task %s", task.TaskID)
}
if shouldSettle {
+3 -5
View File
@@ -3,7 +3,6 @@ package service
import (
"errors"
"fmt"
"log"
"math"
"path/filepath"
"strings"
@@ -12,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
constant2 "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/types"
@@ -111,7 +111,7 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea
width := config.Width
height := config.Height
log.Printf("format: %s, width: %d, height: %d", format, width, height)
logger.LogDebug(c, "image token input: format=%s, width=%d, height=%d", format, width, height)
if isPatchBased {
// 32x32 patch-based calculation with 1536 cap and model multiplier
@@ -171,9 +171,7 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea
tilesH := (finalH + 512 - 1) / 512
tiles := tilesW * tilesH
if common.DebugEnabled {
log.Printf("scaled to: %dx%d, tiles: %d", finalW, finalH, tiles)
}
logger.LogDebug(c, "image token scaled size: width=%d, height=%d, tiles=%d", finalW, finalH, tiles)
return tiles*tileTokens + baseTokens, nil
}
+3
View File
@@ -22,6 +22,9 @@ var Chats = []map[string]string{
{
"CC Switch": "ccswitch",
},
{
"DeepChat": "deepchat://provider/install?v=1&data={deepchatConfig}",
},
{
"Lobe Chat 官方示例": "https://chat-preview.lobehub.com/?settings={\"keyVaults\":{\"openai\":{\"apiKey\":\"{key}\",\"baseURL\":\"{address}/v1\"}}}",
},
+1 -1
View File
@@ -17,7 +17,7 @@ var (
"blue": true, "green": true, "cyan": true, "purple": true, "pink": true,
"red": true, "orange": true, "amber": true, "yellow": true, "lime": true,
"light-green": true, "teal": true, "light-blue": true, "indigo": true,
"violet": true, "grey": true,
"violet": true, "grey": true, "slate": true,
}
slugRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
)
@@ -3,7 +3,7 @@ package operation_setting
import "github.com/QuantumNous/new-api/setting/config"
type ChannelAffinityKeySource struct {
Type string `json:"type"` // context_int, context_string, gjson
Type string `json:"type"` // context_int, context_string, request_header, gjson
Key string `json:"key,omitempty"`
Path string `json:"path,omitempty"`
}
@@ -5,8 +5,16 @@ import "github.com/QuantumNous/new-api/setting/config"
type PaymentSetting struct {
AmountOptions []int `json:"amount_options"`
AmountDiscount map[int]float64 `json:"amount_discount"` // 充值金额对应的折扣,例如 100 元 0.9 表示 100 元充值享受 9 折优惠
ComplianceConfirmed bool `json:"compliance_confirmed"`
ComplianceTermsVersion string `json:"compliance_terms_version"`
ComplianceConfirmedAt int64 `json:"compliance_confirmed_at"`
ComplianceConfirmedBy int `json:"compliance_confirmed_by"`
ComplianceConfirmedIP string `json:"compliance_confirmed_ip"`
}
const CurrentComplianceTermsVersion = "v1"
// 默认配置
var paymentSetting = PaymentSetting{
AmountOptions: []int{10, 20, 50, 100, 200, 500},
@@ -21,3 +29,8 @@ func init() {
func GetPaymentSetting() *PaymentSetting {
return &paymentSetting
}
func IsPaymentComplianceConfirmed() bool {
return paymentSetting.ComplianceConfirmed &&
paymentSetting.ComplianceTermsVersion == CurrentComplianceTermsVersion
}
+2 -2
View File
@@ -10,7 +10,7 @@
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"axios": "1.15.0",
"axios": "1.15.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"history": "^5.3.0",
@@ -776,7 +776,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.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
"axios": ["axios@1.15.2", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A=="],
"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=="],
+1 -1
View File
@@ -10,7 +10,7 @@
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"axios": "1.15.0",
"axios": "1.15.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"history": "^5.3.0",
@@ -62,6 +62,7 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
checklist = [],
inputPrompt = '',
requiredText = '',
requiredTextParts = [],
inputPlaceholder = '',
mismatchText = '',
cancelText = '',
@@ -72,24 +73,68 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
const isMobile = useIsMobile();
const [checkedItems, setCheckedItems] = useState([]);
const [typedText, setTypedText] = useState('');
const [typedTextParts, setTypedTextParts] = useState([]);
const normalizedRequiredTextParts = useMemo(() => {
let inputIndex = 0;
return requiredTextParts.map((part) => {
if (part.type === 'input') {
const normalizedPart = { ...part, inputIndex };
inputIndex += 1;
return normalizedPart;
}
return part;
});
}, [requiredTextParts]);
const requiredTextInputCount = useMemo(
() =>
normalizedRequiredTextParts.filter((part) => part.type === 'input')
.length,
[normalizedRequiredTextParts],
);
const hasSegmentedRequiredText = requiredTextInputCount > 0;
const requiredTextToDisplay = hasSegmentedRequiredText
? normalizedRequiredTextParts.map((part) => part.text).join('')
: requiredText;
useEffect(() => {
if (!visible) return;
setCheckedItems(Array(checklist.length).fill(false));
setTypedText('');
}, [visible, checklist.length]);
setTypedTextParts(Array(requiredTextInputCount).fill(''));
}, [visible, checklist.length, requiredTextInputCount]);
const allChecked = useMemo(() => {
if (checklist.length === 0) return true;
return checkedItems.length === checklist.length && checkedItems.every(Boolean);
return (
checkedItems.length === checklist.length && checkedItems.every(Boolean)
);
}, [checkedItems, checklist.length]);
const typedMatched = useMemo(() => {
if (hasSegmentedRequiredText) {
return normalizedRequiredTextParts.every((part) => {
if (part.type === 'static') return true;
return (
typedTextParts[part.inputIndex ?? 0]?.trim() === part.text.trim()
);
});
}
if (!requiredText) return true;
return typedText.trim() === requiredText.trim();
}, [typedText, requiredText]);
}, [
hasSegmentedRequiredText,
normalizedRequiredTextParts,
requiredText,
typedText,
typedTextParts,
]);
const detailText = useMemo(() => detailItems.join(', '), [detailItems]);
const hasTypedRequiredText = hasSegmentedRequiredText
? typedTextParts.some((part) => part.trim() !== '')
: typedText.length > 0;
const canConfirm = allChecked && typedMatched;
const handleChecklistChange = useCallback((index, checked) => {
@@ -100,6 +145,14 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
});
}, []);
const handleTextPartChange = useCallback((index, value) => {
setTypedTextParts((previous) => {
const next = [...previous];
next[index] = value;
return next;
});
}, []);
return (
<Modal
visible={visible}
@@ -134,7 +187,6 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
}
>
<div className='flex flex-col gap-4'>
<RiskMarkdownBlock markdownContent={markdownContent} />
{detailItems.length > 0 ? (
@@ -176,7 +228,7 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
</div>
) : null}
{requiredText ? (
{requiredTextToDisplay ? (
<div
className='flex flex-col gap-2 rounded-lg'
style={{
@@ -187,19 +239,51 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
>
{inputPrompt ? <Text strong>{inputPrompt}</Text> : null}
<div className='font-mono text-xs break-all rounded-md p-2 bg-gray-50 border border-gray-200'>
{requiredText}
{requiredTextToDisplay}
</div>
<Input
value={typedText}
onChange={setTypedText}
placeholder={inputPlaceholder}
autoFocus={visible}
onCopy={(event) => event.preventDefault()}
onCut={(event) => event.preventDefault()}
onPaste={(event) => event.preventDefault()}
onDrop={(event) => event.preventDefault()}
/>
{!typedMatched && typedText ? (
{hasSegmentedRequiredText ? (
<div className='flex flex-wrap items-center gap-2'>
{normalizedRequiredTextParts.map((part, index) =>
part.type === 'static' ? (
<span
key={`static-${index}`}
className='select-none rounded-md border border-gray-200 bg-white px-2 py-1 font-mono text-sm text-gray-500'
>
{part.text}
</span>
) : (
<Input
key={`input-${index}`}
value={typedTextParts[part.inputIndex ?? 0] ?? ''}
onChange={(value) =>
handleTextPartChange(part.inputIndex ?? 0, value)
}
placeholder={
part.placeholder || part.text || inputPlaceholder
}
autoFocus={visible && part.inputIndex === 0}
onCopy={(event) => event.preventDefault()}
onCut={(event) => event.preventDefault()}
onPaste={(event) => event.preventDefault()}
onDrop={(event) => event.preventDefault()}
style={{ width: isMobile ? '100%' : 260 }}
/>
),
)}
</div>
) : (
<Input
value={typedText}
onChange={setTypedText}
placeholder={inputPlaceholder}
autoFocus={visible}
onCopy={(event) => event.preventDefault()}
onCut={(event) => event.preventDefault()}
onPaste={(event) => event.preventDefault()}
onDrop={(event) => event.preventDefault()}
/>
)}
{!typedMatched && hasTypedRequiredText ? (
<Text type='danger' size='small'>
{mismatchText}
</Text>
+2 -2
View File
@@ -143,12 +143,12 @@ const FooterBar = () => {
Midjourney-Proxy
</a>
<a
href='https://github.com/Calcium-Ion/neko-api-key-tool'
href='https://github.com/Calcium-Ion/new-api-key-tool'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
neko-api-key-tool
new-api-key-tool
</a>
</div>
</div>
+5 -1
View File
@@ -251,7 +251,11 @@ const SiderBar = ({ onNavigate = () => {} }) => {
for (let key in chats[i]) {
let link = chats[i][key];
if (typeof link !== 'string') continue; //
if (link.startsWith('fluent') || link.startsWith('ccswitch')) {
if (
link.startsWith('fluent') ||
link.startsWith('ccswitch') ||
link.startsWith('deepchat')
) {
shouldSkip = true;
break;
}
+196 -49
View File
@@ -18,15 +18,18 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { Banner, Button, Card, Spin, Tabs } from '@douyinfe/semi-ui';
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment';
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway';
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe';
import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem';
import SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo';
import SettingsPaymentGatewayWaffoPancake from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake';
import { API, showError, toBoolean } from '../../helpers';
import { API, showError, showSuccess, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
import RiskAcknowledgementModal from '../common/modals/RiskAcknowledgementModal';
const CURRENT_COMPLIANCE_TERMS_VERSION = 'v1';
const PaymentSetting = () => {
const { t } = useTranslation();
@@ -60,9 +63,59 @@ const PaymentSetting = () => {
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1.0,
WaffoPancakeMinTopUp: 1,
'payment_setting.compliance_confirmed': false,
'payment_setting.compliance_terms_version': '',
'payment_setting.compliance_confirmed_at': 0,
'payment_setting.compliance_confirmed_by': 0,
});
let [loading, setLoading] = useState(false);
const [complianceVisible, setComplianceVisible] = useState(false);
const complianceStatements = [
t('你已合法取得所接入模型 API、账号、密钥和额度的授权;'),
t(
'你承诺仅在已取得上游服务商、模型服务提供方或相关权利方合法授权的范围内使用其 API、账号、密钥、额度及服务能力,不进行未经授权的转售、倒卖、分销或其他违规商业化使用。',
),
t(
'如向中华人民共和国境内公众提供生成式人工智能服务,你将依法履行备案登记、安全评估、内容安全、投诉举报、生成合成内容标识、日志留存、个人信息保护等合规义务;',
),
t(
'你承诺不会利用本系统实施、协助实施或变相实施违反适用法律法规、监管要求、平台规则、社会公共利益或第三方合法权益的行为。',
),
t('你理解并自行承担部署、运营和收费行为产生的法律责任。'),
t(
'你理解本合规提醒仅用于风险提示,不构成法律意见、合规审查结论或对你使用本系统行为合法性的保证;你应根据实际业务场景自行咨询专业法律或合规顾问。',
),
];
const requiredComplianceText = t(
'我已阅读并理解上述合规提醒,知悉相关法律风险,并确认自行承担部署、运营和收费行为产生的法律责任',
);
const requiredComplianceTextParts = [
{
type: 'input',
text: t('我已阅读并理解上述合规提醒'),
},
{ type: 'static', text: t('') },
{
type: 'input',
text: t('知悉相关法律风险'),
},
{ type: 'static', text: t('') },
{
type: 'input',
text: t('并确认自行承担部署'),
},
{ type: 'static', text: t('、') },
{
type: 'input',
text: t('运营和收费行为产生的法律责任'),
},
];
const complianceConfirmed =
inputs['payment_setting.compliance_confirmed'] &&
inputs['payment_setting.compliance_terms_version'] ===
CURRENT_COMPLIANCE_TERMS_VERSION;
const getOptions = async () => {
const res = await API.get('/api/option/');
@@ -104,6 +157,16 @@ const PaymentSetting = () => {
newInputs['AmountDiscount'] = item.value;
}
break;
case 'payment_setting.compliance_confirmed':
newInputs[item.key] = toBoolean(item.value);
break;
case 'payment_setting.compliance_confirmed_at':
case 'payment_setting.compliance_confirmed_by':
newInputs[item.key] = parseInt(item.value) || 0;
break;
case 'payment_setting.compliance_terms_version':
newInputs[item.key] = item.value;
break;
case 'Price':
case 'MinTopUp':
case 'StripeUnitPrice':
@@ -154,59 +217,143 @@ const PaymentSetting = () => {
onRefresh();
}, []);
const confirmCompliance = async () => {
try {
const res = await API.post('/api/option/payment_compliance', {
confirmed: true,
});
if (res.data.success) {
showSuccess(t('合规声明确认成功'));
setComplianceVisible(false);
await onRefresh();
} else {
showError(res.data.message || t('确认失败'));
}
} catch (error) {
showError(t('确认失败'));
}
};
return (
<>
<Spin spinning={loading} size='large'>
<Card style={{ marginTop: '10px' }}>
<Tabs
type='card'
defaultActiveKey='general'
contentStyle={{ paddingTop: 24 }}
{!complianceConfirmed ? (
<Banner
type='warning'
title={t('需要确认合规声明')}
description={
<div className='flex flex-col gap-2'>
<span>
{t(
'确认前,支付、兑换码、订阅计划和邀请返利功能将保持锁定。',
)}
</span>
<Button
type='warning'
theme='solid'
onClick={() => setComplianceVisible(true)}
>
{t('确认合规声明')}
</Button>
</div>
}
closeIcon={null}
style={{ marginBottom: 16 }}
fullMode={false}
/>
) : (
<Banner
type='success'
title={t('合规声明已确认')}
description={t('确认时间:{{time}},确认用户:#{{userId}}', {
time: inputs['payment_setting.compliance_confirmed_at']
? new Date(
inputs['payment_setting.compliance_confirmed_at'] * 1000,
).toLocaleString()
: '-',
userId:
inputs['payment_setting.compliance_confirmed_by'] || '-',
})}
closeIcon={null}
style={{ marginBottom: 16 }}
fullMode={false}
/>
)}
<div
style={
complianceConfirmed
? undefined
: { opacity: 0.4, pointerEvents: 'none' }
}
>
<Tabs.TabPane tab={t('通用设置')} itemKey='general'>
<SettingsGeneralPayment
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('易支付设置')} itemKey='epay'>
<SettingsPaymentGateway
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Stripe 设置')} itemKey='stripe'>
<SettingsPaymentGatewayStripe
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Creem 设置')} itemKey='creem'>
<SettingsPaymentGatewayCreem
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Waffo 设置')} itemKey='waffo'>
<SettingsPaymentGatewayWaffo
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
{/*<Tabs.TabPane tab={t('Waffo Pancake 设置')} itemKey='waffo-pancake'>*/}
{/* <SettingsPaymentGatewayWaffoPancake*/}
{/* options={inputs}*/}
{/* refresh={onRefresh}*/}
{/* hideSectionTitle*/}
{/* />*/}
{/*</Tabs.TabPane>*/}
</Tabs>
<Tabs
type='card'
defaultActiveKey='general'
contentStyle={{ paddingTop: 24 }}
>
<Tabs.TabPane tab={t('通用设置')} itemKey='general'>
<SettingsGeneralPayment
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('易支付设置')} itemKey='epay'>
<SettingsPaymentGateway
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Stripe 设置')} itemKey='stripe'>
<SettingsPaymentGatewayStripe
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Creem 设置')} itemKey='creem'>
<SettingsPaymentGatewayCreem
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Waffo 设置')} itemKey='waffo'>
<SettingsPaymentGatewayWaffo
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
{/*<Tabs.TabPane tab={t('Waffo Pancake 设置')} itemKey='waffo-pancake'>*/}
{/* <SettingsPaymentGatewayWaffoPancake*/}
{/* options={inputs}*/}
{/* refresh={onRefresh}*/}
{/* hideSectionTitle*/}
{/* />*/}
{/*</Tabs.TabPane>*/}
</Tabs>
</div>
</Card>
<RiskAcknowledgementModal
visible={complianceVisible}
title={t('确认合规声明')}
markdownContent={t(
'该操作将启用支付、兑换码、订阅计划和邀请返利相关功能。请仔细阅读并确认以下声明。',
)}
checklist={complianceStatements}
inputPrompt={t('请输入以下文字以确认:')}
requiredText={requiredComplianceText}
requiredTextParts={requiredComplianceTextParts}
inputPlaceholder={t('请输入确认文案')}
mismatchText={t('输入内容与要求文案不一致')}
cancelText={t('取消')}
confirmText={t('确认并启用')}
onCancel={() => setComplianceVisible(false)}
onConfirm={confirmCompliance}
/>
</Spin>
</>
);
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
const SubscriptionsActions = ({ openCreate, t }) => {
const SubscriptionsActions = ({ openCreate, t, disabled = false }) => {
return (
<div className='flex gap-2 w-full md:w-auto'>
<Button
@@ -28,6 +28,7 @@ const SubscriptionsActions = ({ openCreate, t }) => {
className='w-full md:w-auto'
onClick={openCreate}
size='small'
disabled={disabled}
>
{t('新建套餐')}
</Button>
@@ -228,7 +228,11 @@ const renderPaymentConfig = (text, record, t, enableEpay) => {
);
};
const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
const renderOperations = (
text,
record,
{ openEdit, setPlanEnabled, t, complianceConfirmed },
) => {
const isEnabled = record?.plan?.enabled;
const handleToggle = () => {
@@ -256,11 +260,18 @@ const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
type='tertiary'
size='small'
onClick={() => openEdit(record)}
disabled={!complianceConfirmed}
>
{t('编辑')}
</Button>
{isEnabled ? (
<Button theme='light' type='danger' size='small' onClick={handleToggle}>
<Button
theme='light'
type='danger'
size='small'
onClick={handleToggle}
disabled={!complianceConfirmed}
>
{t('禁用')}
</Button>
) : (
@@ -269,6 +280,7 @@ const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
type='primary'
size='small'
onClick={handleToggle}
disabled={!complianceConfirmed}
>
{t('启用')}
</Button>
@@ -282,6 +294,7 @@ export const getSubscriptionsColumns = ({
openEdit,
setPlanEnabled,
enableEpay,
complianceConfirmed = true,
}) => {
return [
{
@@ -351,7 +364,12 @@ export const getSubscriptionsColumns = ({
fixed: 'right',
width: 160,
render: (text, record) =>
renderOperations(text, record, { openEdit, setPlanEnabled, t }),
renderOperations(text, record, {
openEdit,
setPlanEnabled,
t,
complianceConfirmed,
}),
},
];
};

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