Compare commits

...

30 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
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
116 changed files with 2173 additions and 905 deletions
+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
}
+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,
+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) {
+8 -9
View File
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
func GetPerfMetricsSummary(c *gin.Context) {
@@ -18,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,
@@ -72,12 +74,9 @@ func GetPerfMetrics(c *gin.Context) {
}
func filterActiveGroups(groups []perfmetrics.GroupResult) []perfmetrics.GroupResult {
activeGroups := ratio_setting.GetGroupRatioCopy()
filtered := make([]perfmetrics.GroupResult, 0, len(groups))
for _, g := range groups {
if _, ok := activeGroups[g.Group]; ok || g.Group == "auto" {
filtered = append(filtered, g)
}
}
return filtered
activeRatios := ratio_setting.GetGroupRatioCopy()
return lo.Filter(groups, func(g perfmetrics.GroupResult, _ int) bool {
_, ok := activeRatios[g.Group]
return ok || g.Group == "auto"
})
}
-40
View File
@@ -3,51 +3,11 @@ package controller
import (
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
func isRankingsEnabled() bool {
common.OptionMapRWMutex.RLock()
raw := common.OptionMap["HeaderNavModules"]
common.OptionMapRWMutex.RUnlock()
if raw == "" {
return true
}
var parsed map[string]interface{}
if err := common.Unmarshal([]byte(raw), &parsed); err != nil {
return true
}
rankings, ok := parsed["rankings"]
if !ok {
return true
}
switch v := rankings.(type) {
case bool:
return v
case map[string]interface{}:
if enabled, ok := v["enabled"]; ok {
if b, ok := enabled.(bool); ok {
return b
}
}
return true
}
return true
}
func GetRankings(c *gin.Context) {
if !isRankingsEnabled() {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "rankings is disabled",
})
return
}
result, err := service.GetRankingsSnapshot(c.DefaultQuery("period", "week"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
+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
@@ -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设置",
+15 -9
View File
@@ -264,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 {
@@ -276,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
}
@@ -567,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
}
@@ -610,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
}
@@ -778,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) {
@@ -872,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
}
+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)
}
+91 -40
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
}
@@ -624,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)
}
@@ -647,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()
@@ -668,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 {
@@ -828,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
}
@@ -857,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)
@@ -1015,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)
}
+30 -35
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
@@ -308,15 +309,9 @@ 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)
}
@@ -397,16 +392,8 @@ 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)
}
@@ -443,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")
})
}
+2 -2
View File
@@ -35,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
+19 -2
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
@@ -193,6 +199,17 @@ func QuerySummaryAll(hours int) (SummaryAllResult, error) {
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 {
+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)
+10 -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")
}
}()
}
+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 {
+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 -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)
}
+3 -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)
+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 {
+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])
//}
+1 -2
View File
@@ -3,7 +3,6 @@ package service
import (
"errors"
"fmt"
"log"
"math"
"strings"
"time"
@@ -112,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)
}
+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
}
+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"`
}
@@ -69,6 +69,7 @@ const KEY_RULES = 'channel_affinity_setting.rules';
const KEY_SOURCE_TYPES = [
{ label: 'context_int', value: 'context_int' },
{ label: 'context_string', value: 'context_string' },
{ label: 'request_header', value: 'request_header' },
{ label: 'gjson', value: 'gjson' },
];
@@ -659,7 +660,11 @@ export default function SettingsChannelAffinity(props) {
const xs = (keySources || []).map(normalizeKeySource).filter((x) => x.type);
if (xs.length === 0) return { ok: false, message: 'Key 来源不能为空' };
for (const x of xs) {
if (x.type === 'context_int' || x.type === 'context_string') {
if (
x.type === 'context_int' ||
x.type === 'context_string' ||
x.type === 'request_header'
) {
if (!x.key) return { ok: false, message: 'Key 不能为空' };
} else if (x.type === 'gjson') {
if (!x.path) return { ok: false, message: 'Path 不能为空' };
@@ -1316,7 +1321,7 @@ export default function SettingsChannelAffinity(props) {
</Space>
<Text type='tertiary' size='small'>
{t(
'context_int/context_string 从请求上下文读取;gjson 从入口请求的 JSON body 按 gjson path 读取。',
'context_int/context_string 从请求上下文读取;request_header 从用户请求头读取;gjson 从入口请求的 JSON body 按 gjson path 读取。',
)}
</Text>
<div style={{ marginTop: 8, marginBottom: 8 }}>
@@ -1358,7 +1363,7 @@ export default function SettingsChannelAffinity(props) {
return (
<Input
placeholder={
isGjson ? 'metadata.conversation_id' : 'user_id'
isGjson ? 'metadata.conversation_id' : 'X-Affinity-Key'
}
aria-label={t('Key 或 Path')}
value={isGjson ? src.path : src.key}
+2
View File
@@ -14,6 +14,8 @@
/>
<meta name="theme-color" content="#fff" />
<!--umami-->
<!--Google Analytics-->
</head>
<body>
+8 -2
View File
@@ -107,7 +107,10 @@ export function DataTableFacetedFilter<TData, TValue>({
</>
)}
</PopoverTrigger>
<PopoverContent className='w-[200px] p-0' align='start'>
<PopoverContent
className='min-w-[200px] max-w-[360px] p-0'
align='start'
>
<Command>
<CommandInput placeholder={title} />
<CommandList>
@@ -159,7 +162,10 @@ export function DataTableFacetedFilter<TData, TValue>({
) : option.icon ? (
<option.icon className='text-muted-foreground size-4' />
) : null}
<span className='min-w-0 flex-1 truncate'>
<span
className='min-w-0 flex-1 truncate'
title={t(option.label)}
>
{t(option.label)}
</span>
{typeof option.count === 'number' ? (
+10 -11
View File
@@ -17,6 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback } from 'react'
import {
INTERFACE_LANGUAGE_OPTIONS,
normalizeInterfaceLanguage,
} from '@/i18n/languages'
import { Languages, Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
@@ -30,18 +34,10 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
const languages = [
{ code: 'en', label: 'English' },
{ code: 'zh', label: '中文' },
{ code: 'fr', label: 'Français' },
{ code: 'ru', label: 'Русский' },
{ code: 'ja', label: '日本語' },
{ code: 'vi', label: 'Tiếng Việt' },
]
export function LanguageSwitcher() {
const { i18n, t } = useTranslation()
const user = useAuthStore((s) => s.auth.user)
const currentLanguage = normalizeInterfaceLanguage(i18n.language)
const handleChangeLanguage = useCallback(
async (code: string) => {
@@ -66,7 +62,7 @@ export function LanguageSwitcher() {
<span className='sr-only'>{t('Change language')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{languages.map((lang) => (
{INTERFACE_LANGUAGE_OPTIONS.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => handleChangeLanguage(lang.code)}
@@ -74,7 +70,10 @@ export function LanguageSwitcher() {
{lang.label}
<Check
size={14}
className={cn('ms-auto', i18n.language !== lang.code && 'hidden')}
className={cn(
'ms-auto',
currentLanguage !== lang.code && 'hidden'
)}
/>
</DropdownMenuItem>
))}
@@ -79,7 +79,9 @@ function ChatMenuItem({
/>
}
>
<span>{preset.name}</span>
<span className='min-w-0 flex-1 truncate whitespace-nowrap'>
{preset.name}
</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
@@ -95,11 +97,13 @@ function ChatMenuItem({
isActive={false}
className='justify-between'
>
<span>{preset.name}</span>
<span className='min-w-0 flex-1 truncate whitespace-nowrap'>
{preset.name}
</span>
{loading ? (
<Loader2 className='h-4 w-4 animate-spin' />
<Loader2 className='h-4 w-4 shrink-0 animate-spin' />
) : (
<ExternalLink className='h-4 w-4' />
<ExternalLink className='h-4 w-4 shrink-0' />
)}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
+157 -15
View File
@@ -16,8 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect } from 'react'
import { Link, useRouterState } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { cn } from '@/lib/utils'
@@ -25,6 +25,14 @@ import { useNotifications } from '@/hooks/use-notifications'
import { useSystemConfig } from '@/hooks/use-system-config'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { LanguageSwitcher } from '@/components/language-switcher'
import { NotificationButton } from '@/components/notification-button'
@@ -35,6 +43,13 @@ import { defaultTopNavLinks } from '../config/top-nav.config'
import type { TopNavLink } from '../types'
import { HeaderLogo } from './header-logo'
const AUTH_PROMPT_SECONDS = 5
type AuthPromptTarget = {
title: string
href: string
}
export interface PublicHeaderProps {
navLinks?: TopNavLink[]
mobileLinks?: TopNavLink[]
@@ -65,8 +80,13 @@ export function PublicHeader(props: PublicHeaderProps) {
} = props
const { t } = useTranslation()
const navigate = useNavigate()
const [scrolled, setScrolled] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const [authPromptTarget, setAuthPromptTarget] =
useState<AuthPromptTarget | null>(null)
const [authPromptSecondsLeft, setAuthPromptSecondsLeft] =
useState(AUTH_PROMPT_SECONDS)
const { auth } = useAuthStore()
const {
systemName,
@@ -98,6 +118,67 @@ export function PublicHeader(props: PublicHeaderProps) {
}
}, [mobileOpen])
useEffect(() => {
if (!authPromptTarget) return
const intervalId = window.setInterval(() => {
setAuthPromptSecondsLeft((seconds) => Math.max(seconds - 1, 0))
}, 1000)
const timeoutId = window.setTimeout(() => {
const redirect = authPromptTarget.href
setAuthPromptTarget(null)
navigate({ to: '/sign-in', search: { redirect } })
}, AUTH_PROMPT_SECONDS * 1000)
return () => {
window.clearInterval(intervalId)
window.clearTimeout(timeoutId)
}
}, [authPromptTarget, navigate])
const closeAuthPrompt = useCallback(() => {
setAuthPromptTarget(null)
setAuthPromptSecondsLeft(AUTH_PROMPT_SECONDS)
}, [])
const navigateToSignIn = useCallback(() => {
const redirect = authPromptTarget?.href || '/'
setAuthPromptTarget(null)
navigate({ to: '/sign-in', search: { redirect } })
}, [authPromptTarget?.href, navigate])
const handleNavLinkClick = useCallback(
(
event: React.MouseEvent<HTMLAnchorElement>,
link: TopNavLink,
closeMobile = false
) => {
if (link.disabled) {
event.preventDefault()
return
}
if (link.requiresAuth) {
event.preventDefault()
if (closeMobile) {
setMobileOpen(false)
}
setAuthPromptSecondsLeft(AUTH_PROMPT_SECONDS)
setAuthPromptTarget({
title: t(link.title),
href: link.href,
})
return
}
if (closeMobile) {
setMobileOpen(false)
}
},
[t]
)
return (
<>
<header className='pointer-events-none fixed inset-x-0 top-0 z-50'>
@@ -150,7 +231,13 @@ export function PublicHeader(props: PublicHeaderProps) {
href={link.href}
target='_blank'
rel='noopener noreferrer'
className='text-muted-foreground hover:text-foreground rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200'
aria-disabled={link.disabled}
tabIndex={link.disabled ? -1 : undefined}
onClick={(event) => handleNavLinkClick(event, link)}
className={cn(
'text-muted-foreground hover:text-foreground rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
link.disabled && 'pointer-events-none opacity-50'
)}
>
{t(link.title)}
</a>
@@ -160,11 +247,14 @@ export function PublicHeader(props: PublicHeaderProps) {
<Link
key={i}
to={link.href}
disabled={link.disabled}
onClick={(event) => handleNavLinkClick(event, link)}
className={cn(
'rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
isActive
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'
: 'text-muted-foreground hover:text-foreground',
link.disabled && 'pointer-events-none opacity-50'
)}
>
{t(link.title)}
@@ -260,21 +350,42 @@ export function PublicHeader(props: PublicHeaderProps) {
<nav className='flex flex-col gap-1'>
{links.map((link, i) => {
const isActive = pathname === link.href
const linkClassName = cn(
'flex items-center gap-3 py-3 text-base font-medium tracking-tight transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
mobileOpen
? 'translate-y-0 opacity-100'
: 'translate-y-4 opacity-0',
isActive ? 'text-foreground' : 'text-muted-foreground',
link.disabled && 'pointer-events-none opacity-50'
)
const transitionStyle = {
transitionDelay: mobileOpen ? `${100 + i * 50}ms` : '0ms',
}
if (link.external) {
return (
<a
key={i}
href={link.href}
target='_blank'
rel='noopener noreferrer'
aria-disabled={link.disabled}
tabIndex={link.disabled ? -1 : undefined}
onClick={(event) => handleNavLinkClick(event, link, true)}
className={linkClassName}
style={transitionStyle}
>
{t(link.title)}
</a>
)
}
return (
<Link
key={i}
to={link.href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-3 py-3 text-base font-medium tracking-tight transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
mobileOpen
? 'translate-y-0 opacity-100'
: 'translate-y-4 opacity-0',
isActive ? 'text-foreground' : 'text-muted-foreground'
)}
style={{
transitionDelay: mobileOpen ? `${100 + i * 50}ms` : '0ms',
}}
disabled={link.disabled}
onClick={(event) => handleNavLinkClick(event, link, true)}
className={linkClassName}
style={transitionStyle}
>
{t(link.title)}
</Link>
@@ -304,6 +415,37 @@ export function PublicHeader(props: PublicHeaderProps) {
</div>
</div>
<Dialog
open={!!authPromptTarget}
onOpenChange={(open) => {
if (!open) {
closeAuthPrompt()
}
}}
>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Sign in required')}</DialogTitle>
<DialogDescription>
{t('Please sign in to view {{module}}.', {
module: authPromptTarget?.title || '',
})}
</DialogDescription>
</DialogHeader>
<div className='bg-muted/40 text-muted-foreground rounded-lg px-3 py-2 text-sm'>
{t('Redirecting to sign in in {{seconds}} seconds.', {
seconds: authPromptSecondsLeft,
})}
</div>
<DialogFooter>
<Button variant='outline' onClick={closeAuthPrompt}>
{t('Cancel')}
</Button>
<Button onClick={navigateToSignIn}>{t('Sign in now')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Notification Dialog */}
{showNotifications && (
<NotificationDialog
+1
View File
@@ -97,5 +97,6 @@ export type TopNavLink = {
href: string
isActive?: boolean
disabled?: boolean
requiresAuth?: boolean
external?: boolean
}
+38
View File
@@ -0,0 +1,38 @@
import { cn } from '@/lib/utils'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
interface TruncatedTextProps {
text: string
className?: string
maxWidth?: string
side?: 'top' | 'bottom' | 'left' | 'right'
}
export function TruncatedText({
text,
className,
maxWidth = 'max-w-[200px]',
side = 'top',
}: TruncatedTextProps) {
return (
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<span className={cn('block truncate', maxWidth, className)} />
}
>
{text}
</TooltipTrigger>
<TooltipContent side={side} className='max-w-xs break-all'>
{text}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
+5 -1
View File
@@ -27,6 +27,7 @@ import {
type FieldValues,
} from 'react-hook-form'
import { useRender } from '@base-ui/react/use-render'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
@@ -153,12 +154,15 @@ function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField()
const { t } = useTranslation()
const body = error ? String(error?.message ?? '') : props.children
if (!body) {
return null
}
const translatedBody = typeof body === 'string' ? t(body) : body
return (
<p
data-slot='form-message'
@@ -166,7 +170,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
{translatedBody}
</p>
)
}
@@ -107,7 +107,7 @@ export function ForgotPasswordForm({
)}
/>
<Button className='mt-2' disabled={isLoading || isActive}>
<Button type='submit' className='mt-2' disabled={isLoading || isActive}>
{isActive ? `Resend (${secondsLeft}s)` : 'Send reset email'}
{isLoading ? <Loader2 className='animate-spin' /> : <ArrowRight />}
</Button>
@@ -112,8 +112,8 @@ export function ResetPasswordConfirm({
</h2>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{newPassword
? 'Your password has been reset successfully'
: 'Confirm the reset request to generate a new password.'}
? t('auth.resetPasswordConfirm.success')
: t('auth.resetPasswordConfirm.description')}
</p>
</div>
@@ -178,10 +178,12 @@ export function ResetPasswordConfirm({
}
>
{newPassword
? 'Return to login'
? t('auth.resetPasswordConfirm.backToLogin')
: isActive
? `Retry (${secondsLeft}s)`
: 'Confirm reset password'}
? t('auth.resetPasswordConfirm.retry', {
seconds: secondsLeft,
})
: t('auth.resetPasswordConfirm.confirm')}
</Button>
{!newPassword && (
@@ -313,7 +313,7 @@ export function UserAuthForm({
<FormMessage />
<Link
to='/forgot-password'
className='text-muted-foreground absolute end-0 -top-0.5 text-sm font-medium hover:opacity-75'
className='text-muted-foreground absolute end-0 -top-0.5 z-10 text-sm font-medium hover:opacity-75'
>
{t('Forgot password?')}
</Link>
+1 -1
View File
@@ -35,7 +35,7 @@ export function SignIn() {
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
{t('Sign in')}
</h2>
{!status?.self_use_mode_enabled && (
{!status?.self_use_mode_enabled && status?.register_enabled !== false && (
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t("Don't have an account?")}{' '}
<Link
@@ -155,7 +155,7 @@ export function SignUpForm({
password: data.password,
email: data.email || undefined,
verification_code: verificationCode || undefined,
aff: getAffiliateCode(),
aff_code: getAffiliateCode(),
turnstile: turnstileToken,
})
+1 -1
View File
@@ -37,7 +37,7 @@ export interface RegisterPayload {
password: string
email?: string
verification_code?: string
aff?: string
aff_code?: string
turnstile?: string
}
@@ -36,6 +36,7 @@ import {
} from '@/lib/format'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn, truncateText } from '@/lib/utils'
import { TruncatedText } from '@/components/truncated-text'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
@@ -556,7 +557,11 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
<div className='flex items-center gap-2'>
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-1.5'>
<span className='font-medium'>{truncateText(name, 30)}</span>
<TruncatedText
text={name}
className='font-medium'
maxWidth='max-w-[180px]'
/>
{isPassThrough && (
<TooltipProvider delay={100}>
<Tooltip>
@@ -232,13 +232,20 @@ export function ChannelTestDialog({
} catch (error: unknown) {
updateTestResult(model, {
status: 'error',
error: error instanceof Error ? error.message : 'Test failed',
error: error instanceof Error ? error.message : t('Test failed'),
})
} finally {
markModelTesting(model, false)
}
},
[currentRow, endpointType, isStreamTest, markModelTesting, updateTestResult]
[
currentRow,
endpointType,
isStreamTest,
markModelTesting,
t,
updateTestResult,
]
)
const handleBatchTest = useCallback(
@@ -65,6 +65,8 @@ type FetchModelsDialogProps = {
onModelsSelected?: (models: string[]) => void
redirectModels?: string[]
redirectSourceModels?: string[]
customFetcher?: () => Promise<string[]>
existingModelsOverride?: string[]
}
export function FetchModelsDialog({
@@ -73,6 +75,8 @@ export function FetchModelsDialog({
onModelsSelected,
redirectModels = [],
redirectSourceModels = [],
customFetcher,
existingModelsOverride,
}: FetchModelsDialogProps) {
const { t } = useTranslation()
const { currentRow } = useChannels()
@@ -85,8 +89,10 @@ export function FetchModelsDialog({
// Parse existing models
const existingModels = useMemo(
() => parseModelsString(currentRow?.models || ''),
[currentRow?.models]
() =>
existingModelsOverride ??
parseModelsString(currentRow?.models || ''),
[existingModelsOverride, currentRow?.models]
)
// Categorize models with redirect models
@@ -121,26 +127,33 @@ export function FetchModelsDialog({
}, [fetchedModelSet, redirectSourceKeysSet, searchKeyword, selectedModels])
useEffect(() => {
if (open && currentRow) {
if (open && (currentRow || customFetcher)) {
handleFetchModels()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentRow?.id])
}, [open, currentRow?.id, customFetcher])
const handleFetchModels = async () => {
if (!currentRow) return
if (!currentRow && !customFetcher) return
setIsFetching(true)
try {
const response = await fetchUpstreamModels(currentRow.id)
if (response.success) {
const list = Array.isArray(response.data) ? response.data : []
if (customFetcher) {
const list = await customFetcher()
setFetchedModels(list)
setSelectedModels(existingModels)
toast.success(t('Fetched {{count}} models', { count: list.length }))
} else {
toast.error(response.message || t('Failed to fetch models'))
setFetchedModels([])
const response = await fetchUpstreamModels(currentRow!.id)
if (response.success) {
const list = Array.isArray(response.data) ? response.data : []
setFetchedModels(list)
setSelectedModels(existingModels)
toast.success(t('Fetched {{count}} models', { count: list.length }))
} else {
toast.error(response.message || t('Failed to fetch models'))
setFetchedModels([])
}
}
} catch (error: unknown) {
toast.error(
@@ -153,8 +166,6 @@ export function FetchModelsDialog({
}
const handleSave = async () => {
if (!currentRow) return
// If onModelsSelected callback is provided, use it (form filling mode)
if (onModelsSelected) {
onModelsSelected(selectedModels)
@@ -164,6 +175,7 @@ export function FetchModelsDialog({
}
// Otherwise, directly save to API (standalone mode)
if (!currentRow) return
setIsSaving(true)
try {
const modelsString = selectedModels.join(',')
@@ -357,12 +369,16 @@ export function FetchModelsDialog({
<DialogHeader>
<DialogTitle>{t('Fetch Models')}</DialogTitle>
<DialogDescription>
{t('Fetch available models for:')}{' '}
<strong>{currentRow?.name}</strong>
{currentRow
? <>
{t('Fetch available models for:')}{' '}
<strong>{currentRow.name}</strong>
</>
: t('Fetch available models from upstream')}
</DialogDescription>
</DialogHeader>
{!currentRow ? (
{!currentRow && !customFetcher ? (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
</div>
@@ -138,7 +138,7 @@ export function MultiKeyManageDialog({
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : 'Failed to load key status'
error instanceof Error ? error.message : t('Failed to load key status')
)
} finally {
setIsLoading(false)
@@ -181,7 +181,7 @@ export function MultiKeyManageDialog({
}
if (response?.success) {
toast.success(response.message || 'Operation successful')
toast.success(response.message || t('Operation successful'))
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
// Reload data - reset to page 1 for bulk actions
@@ -193,10 +193,12 @@ export function MultiKeyManageDialog({
loadKeyStatus(currentPage, pageSize)
}
} else {
toast.error(response?.message || 'Operation failed')
toast.error(response?.message || t('Operation failed'))
}
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : 'Operation failed')
toast.error(
error instanceof Error ? error.message : t('Operation failed')
)
} finally {
setIsPerformingAction(false)
setConfirmAction(null)
@@ -301,7 +301,6 @@ export function ChannelMutateDrawer({
const { setOpen } = useChannels()
const [isSubmitting, setIsSubmitting] = useState(false)
const [customModel, setCustomModel] = useState('')
const [isFetchingModels, setIsFetchingModels] = useState(false)
const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false)
const [channelKey, setChannelKey] = useState<string | null>(null)
const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
@@ -698,7 +697,7 @@ export function ChannelMutateDrawer({
try {
const res = await getChannelKey(channelId)
if (!res.success) {
throw new Error(res.message || 'Failed to fetch channel key')
throw new Error(res.message || t('Failed to fetch channel key'))
}
const keyValue = res.data?.key ?? ''
@@ -733,7 +732,7 @@ export function ChannelMutateDrawer({
try {
const res = await refreshCodexCredential(channelId)
if (!res.success) {
throw new Error(res.message || 'Failed to refresh credential')
throw new Error(res.message || t('Failed to refresh credential'))
}
toast.success(t('Credential refreshed'))
queryClient.invalidateQueries({
@@ -767,43 +766,29 @@ export function ChannelMutateDrawer({
return
}
// For editing mode, open FetchModelsDialog to let user select
if (isEditing && currentRow) {
setFetchModelsDialogOpen(true)
return
}
// For creation mode, fetch and fill all models
const key = form.getValues('key')
if (!key?.trim()) {
toast.error(t('Please enter API key first'))
return
}
setIsFetchingModels(true)
try {
const response = await fetchModels({
type,
key,
base_url: form.getValues('base_url') || '',
})
if (response.success && response.data) {
updateModels(response.data, true)
toast.success(
t('Fetched {{count}} model(s) from upstream', {
count: response.data.length,
})
)
} else {
toast.error(t('No models fetched from upstream'))
// For creation mode, validate key before opening dialog
if (!isEditing) {
const key = form.getValues('key')
if (!key?.trim()) {
toast.error(t('Please enter API key first'))
return
}
} catch (error: unknown) {
toast.error(getErrorMessage(error) || t('Failed to fetch models'))
} finally {
setIsFetchingModels(false)
}
}, [isEditing, currentRow, form, t, updateModels])
setFetchModelsDialogOpen(true)
}, [isEditing, form, t])
const createModeFetcher = useCallback(async (): Promise<string[]> => {
const response = await fetchModels({
type: form.getValues('type'),
key: form.getValues('key'),
base_url: form.getValues('base_url') || '',
})
if (response.success && response.data) {
return response.data
}
throw new Error(response.message || 'No models fetched from upstream')
}, [form])
// Handle adding custom models
const handleAddCustomModels = useCallback(() => {
@@ -2234,13 +2219,8 @@ export function ChannelMutateDrawer({
variant='outline'
size='sm'
onClick={handleFetchModels}
disabled={isFetchingModels}
>
{isFetchingModels ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<Sparkles className='mr-2 h-4 w-4' />
)}
<Sparkles className='mr-2 h-4 w-4' />
{t('Fetch from Upstream')}
</Button>
)}
@@ -3390,19 +3370,20 @@ export function ChannelMutateDrawer({
/>
)}
{/* Fetch Models Dialog (for editing mode) */}
{isEditing && currentRow && (
<FetchModelsDialog
open={fetchModelsDialogOpen}
onOpenChange={setFetchModelsDialogOpen}
onModelsSelected={(models) => {
// Fill selected models to form
form.setValue('models', formatModelsArray(models))
}}
redirectModels={redirectModelList}
redirectSourceModels={redirectModelKeyList}
/>
)}
{/* Fetch Models Dialog */}
<FetchModelsDialog
open={fetchModelsDialogOpen}
onOpenChange={setFetchModelsDialogOpen}
onModelsSelected={(models) => {
form.setValue('models', formatModelsArray(models))
}}
redirectModels={redirectModelList}
redirectSourceModels={redirectModelKeyList}
customFetcher={!isEditing ? createModeFetcher : undefined}
existingModelsOverride={
!isEditing ? parseModelsString(form.getValues('models') || '') : undefined
}
/>
<SecureVerificationDialog
open={verificationOpen}
+8 -3
View File
@@ -457,8 +457,8 @@ export function transformFormDataToUpdatePayload(
models: formData.models,
group: formatGroups(formData.group),
model_mapping: formData.model_mapping || null,
priority: formData.priority || null,
weight: formData.weight || null,
priority: formData.priority ?? 0,
weight: formData.weight ?? 0,
test_model: formData.test_model || null,
auto_ban: formData.auto_ban ?? 1,
status: formData.status,
@@ -484,7 +484,12 @@ export function transformFormDataToUpdatePayload(
}
})
// Send explicit empty strings for nullable JSON/text fields so GORM updates can clear them.
// Send explicit empty strings for nullable fields so GORM updates can clear them.
payload.base_url = formData.base_url || ''
payload.openai_organization = formData.openai_organization || ''
payload.test_model = formData.test_model || ''
payload.tag = formData.tag || ''
payload.remark = formData.remark || ''
payload.model_mapping = formData.model_mapping || ''
payload.status_code_mapping = formData.status_code_mapping || ''
payload.param_override = formData.param_override || ''
@@ -52,7 +52,10 @@ import {
} from '@/components/page-transition'
import { fetchTokenKey, getApiKeys } from '@/features/keys/api'
import type { ApiKey } from '@/features/keys/types'
import { useApiInfo } from '../../hooks/use-status-data'
import {
useApiInfo,
useDashboardContentVisibility,
} from '../../hooks/use-status-data'
import { AnnouncementsPanel } from './announcements-panel'
import { ApiInfoPanel } from './api-info-panel'
import { FAQPanel } from './faq-panel'
@@ -423,6 +426,12 @@ export function OverviewDashboard() {
const { t } = useTranslation()
const user = useAuthStore((state) => state.auth.user)
const { items: apiInfoItems } = useApiInfo()
const {
apiInfo: showApiInfoPanel,
announcements: showAnnouncementsPanel,
faq: showFAQPanel,
uptimeKuma: showUptimePanel,
} = useDashboardContentVisibility()
const [manualSetupGuideExpanded, setManualSetupGuideExpanded] = useState<
boolean | null
>(() => getSavedSetupGuideExpanded())
@@ -574,6 +583,9 @@ export function OverviewDashboard() {
const completedStepCount = startSteps.filter((step) => step.completed).length
const setupComplete = completedStepCount === startSteps.length
const setupGuideExpanded = manualSetupGuideExpanded ?? !setupComplete
const showLeftContentPanels =
isAdmin || showApiInfoPanel || showAnnouncementsPanel || showFAQPanel
const showContentPanels = showLeftContentPanels || showUptimePanel
const handleSetupGuideToggle = () => {
const nextExpanded = !setupGuideExpanded
@@ -715,27 +727,52 @@ export function OverviewDashboard() {
<SummaryCards />
<CardStaggerContainer className='grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]'>
<div className='grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2'>
{isAdmin && (
<CardStaggerItem className='lg:col-span-2'>
<PerformanceHealthPanel />
{showContentPanels && (
<CardStaggerContainer
className={cn(
'grid grid-cols-1 gap-4',
showLeftContentPanels &&
showUptimePanel &&
'xl:grid-cols-[minmax(0,1fr)_22rem]'
)}
>
{showLeftContentPanels && (
<div
className={cn(
'grid min-w-0 grid-cols-1 gap-4',
(showApiInfoPanel || showAnnouncementsPanel || showFAQPanel) &&
'lg:grid-cols-2'
)}
>
{isAdmin && (
<CardStaggerItem className='lg:col-span-2'>
<PerformanceHealthPanel />
</CardStaggerItem>
)}
{showApiInfoPanel && (
<CardStaggerItem>
<ApiInfoPanel />
</CardStaggerItem>
)}
{showAnnouncementsPanel && (
<CardStaggerItem>
<AnnouncementsPanel />
</CardStaggerItem>
)}
{showFAQPanel && (
<CardStaggerItem>
<FAQPanel />
</CardStaggerItem>
)}
</div>
)}
{showUptimePanel && (
<CardStaggerItem>
<UptimePanel />
</CardStaggerItem>
)}
<CardStaggerItem>
<ApiInfoPanel />
</CardStaggerItem>
<CardStaggerItem>
<AnnouncementsPanel />
</CardStaggerItem>
<CardStaggerItem>
<FAQPanel />
</CardStaggerItem>
</div>
<CardStaggerItem>
<UptimePanel />
</CardStaggerItem>
</CardStaggerContainer>
</CardStaggerContainer>
)}
</div>
)
}
+16 -1
View File
@@ -27,7 +27,7 @@ export function useStatusData<T = unknown>(
dataKey: string
): { items: T[]; loading: boolean } {
const { status, loading } = useStatus()
const enabled = status?.[enabledKey] ?? false
const enabled = status ? status[enabledKey] !== false : false
const items = (enabled ? status?.[dataKey] || [] : []) as T[]
return { items, loading }
@@ -56,3 +56,18 @@ export function useAnnouncements() {
export function useFAQ() {
return useStatusData<FAQItem>('faq_enabled', 'faq')
}
/**
* Get dashboard content panel visibility
*/
export function useDashboardContentVisibility() {
const { status } = useStatus()
const hasStatus = Boolean(status)
return {
apiInfo: hasStatus && status?.api_info_enabled !== false,
announcements: hasStatus && status?.announcements_enabled !== false,
faq: hasStatus && status?.faq_enabled !== false,
uptimeKuma: hasStatus && status?.uptime_kuma_enabled !== false,
}
}
+1 -1
View File
@@ -236,7 +236,7 @@ export function Dashboard() {
<div className='flex flex-wrap items-center justify-between gap-1.5 sm:gap-2'>
{showSectionTabs ? (
<Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='h-auto max-w-full flex-wrap justify-start'>
<TabsList className='group-data-horizontal/tabs:h-auto max-w-full flex-wrap justify-start'>
{visibleSections.map((section) => (
<TabsTrigger key={section} value={section}>
{t(SECTION_META[section].titleKey)}
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useState, type ReactNode } from 'react'
import { useForm } from 'react-hook-form'
import { useForm, type SubmitErrorHandler } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import {
@@ -65,7 +65,7 @@ import { MultiSelect } from '@/components/multi-select'
import { createApiKey, updateApiKey, getApiKey } from '../api'
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import {
apiKeyFormSchema,
getApiKeyFormSchema,
type ApiKeyFormValues,
getApiKeyFormDefaultValues,
transformFormDataToPayload,
@@ -152,9 +152,10 @@ export function ApiKeysMutateDrawer({
})
)
const backendHasAuto = groups.some((g) => g.value === 'auto')
const schema = getApiKeyFormSchema(t)
const form = useForm<ApiKeyFormValues>({
resolver: zodResolver(apiKeyFormSchema),
resolver: zodResolver(schema),
defaultValues: getApiKeyFormDefaultValues(defaultUseAutoGroup),
})
@@ -239,6 +240,10 @@ export function ApiKeysMutateDrawer({
}
}
const onInvalid: SubmitErrorHandler<ApiKeyFormValues> = () => {
toast.error(t('Please fix the highlighted fields before saving'))
}
const handleSetExpiry = (months: number, days: number, hours: number) => {
if (months === 0 && days === 0 && hours === 0) {
form.setValue('expired_time', undefined)
@@ -291,7 +296,7 @@ export function ApiKeysMutateDrawer({
<Form {...form}>
<form
id='api-key-form'
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-3 py-3 sm:space-y-4 sm:px-4 sm:py-4'
>
<ApiKeyFormSection
@@ -605,8 +610,8 @@ export function ApiKeysMutateDrawer({
{t('Close')}
</SheetClose>
<Button
form='api-key-form'
type='submit'
type='button'
onClick={form.handleSubmit(onSubmit)}
disabled={isSubmitting}
className='w-full sm:w-auto'
>
@@ -318,6 +318,7 @@ export function ApiKeysTable() {
columnId: 'status',
title: t('Status'),
options: API_KEY_STATUS_OPTIONS,
singleSelect: true,
},
],
}}
+37 -13
View File
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { z } from 'zod'
import type { TFunction } from 'i18next'
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
import { DEFAULT_GROUP } from '../constants'
import { type ApiKeyFormData, type ApiKey } from '../types'
@@ -25,19 +26,40 @@ import { type ApiKeyFormData, type ApiKey } from '../types'
// Form Schema
// ============================================================================
export const apiKeyFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
remain_quota_dollars: z.number().min(0).optional(),
expired_time: z.date().optional(),
unlimited_quota: z.boolean(),
model_limits: z.array(z.string()),
allow_ips: z.string().optional(),
group: z.string().optional(),
cross_group_retry: z.boolean().optional(),
tokenCount: z.number().min(1).optional(),
})
export function getApiKeyFormSchema(t: TFunction) {
return z
.object({
name: z.string().min(1, t('Please enter a name')),
remain_quota_dollars: z.number().optional(),
expired_time: z.date().optional(),
unlimited_quota: z.boolean(),
model_limits: z.array(z.string()),
allow_ips: z.string().optional(),
group: z.string().optional(),
cross_group_retry: z.boolean().optional(),
tokenCount: z.number().min(1).optional(),
})
.superRefine((data, ctx) => {
if (data.unlimited_quota) {
return
}
export type ApiKeyFormValues = z.infer<typeof apiKeyFormSchema>
if (
data.remain_quota_dollars === undefined ||
data.remain_quota_dollars < 0
) {
ctx.addIssue({
code: 'custom',
path: ['remain_quota_dollars'],
message: t('Quota must be zero or greater'),
})
}
})
}
export type ApiKeyFormValues = z.infer<
ReturnType<typeof getApiKeyFormSchema>
>
// ============================================================================
// Form Defaults
@@ -100,7 +122,9 @@ export function transformApiKeyToFormDefaults(
): ApiKeyFormValues {
return {
name: apiKey.name,
remain_quota_dollars: quotaUnitsToDollars(apiKey.remain_quota),
remain_quota_dollars: apiKey.unlimited_quota
? 0
: quotaUnitsToDollars(apiKey.remain_quota),
expired_time:
apiKey.expired_time > 0
? new Date(apiKey.expired_time * 1000)
+1 -1
View File
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
// Form Utilities
// ============================================================================
export {
apiKeyFormSchema,
getApiKeyFormSchema,
type ApiKeyFormValues,
API_KEY_FORM_DEFAULT_VALUES,
getApiKeyFormDefaultValues,
+1 -1
View File
@@ -142,7 +142,7 @@ function ModelsContent() {
<SectionPageLayout.Content>
<div className='space-y-4'>
<Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='h-auto max-w-full flex-wrap justify-start'>
<TabsList className='group-data-horizontal/tabs:h-auto max-w-full flex-wrap justify-start'>
{MODELS_SECTION_IDS.map((section) => (
<TabsTrigger key={section} value={section}>
{t(SECTION_META[section].titleKey)}
@@ -103,7 +103,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
className='gap-1.5'
>
<ChevronLeft className='size-4' />
{t('Previous')}
{t('Previous page')}
</Button>
<Button
type='button'
@@ -115,7 +115,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
disabled={currentPage >= totalPages}
className='gap-1.5'
>
{t('Next')}
{t('Next page')}
<ChevronRight className='size-4' />
</Button>
</div>
@@ -920,17 +920,17 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
<ModelHeader model={props.model} />
<Tabs defaultValue='overview' className='gap-4'>
<TabsList className='bg-muted/60 h-auto w-full justify-start gap-1 overflow-x-auto rounded-lg p-1'>
<TabsList className='bg-muted/60 grid w-full grid-cols-3 gap-1 rounded-lg p-1 group-data-horizontal/tabs:h-auto'>
{TAB_VALUES.map((value) => {
const Icon = TAB_META[value].icon
return (
<TabsTrigger
key={value}
value={value}
className='h-8 gap-1.5 rounded-md px-3 text-xs sm:text-sm'
className='h-8 min-w-0 gap-1.5 rounded-md px-3 text-xs sm:text-sm'
>
<Icon className='size-3.5' />
<span>{t(TAB_META[value].labelKey)}</span>
<span className='truncate'>{t(TAB_META[value].labelKey)}</span>
</TabsTrigger>
)
})}
@@ -17,6 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useMemo, useState } from 'react'
import {
INTERFACE_LANGUAGE_OPTIONS,
normalizeInterfaceLanguage,
} from '@/i18n/languages'
import { Languages, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -34,24 +38,6 @@ import { updateUserLanguage } from '../api'
import { parseUserSettings } from '../lib'
import type { UserProfile } from '../types'
const LANGUAGE_OPTIONS = [
{ value: 'zh', label: '简体中文' },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'ru', label: 'Русский' },
{ value: 'ja', label: '日本語' },
{ value: 'vi', label: 'Tiếng Việt' },
] as const
function normalizeLanguage(value?: string | null): string {
if (!value) return 'en'
const normalized = value.trim().replace(/_/g, '-').toLowerCase()
if (normalized.startsWith('zh')) return 'zh'
return LANGUAGE_OPTIONS.some((lang) => lang.value === normalized)
? normalized
: 'en'
}
type LanguagePreferencesCardProps = {
profile: UserProfile | null
onProfileUpdate: () => void
@@ -64,7 +50,7 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
const savedLanguage = useMemo(() => {
const settings = parseUserSettings(props.profile?.setting)
return normalizeLanguage(settings.language || i18n.language)
return normalizeInterfaceLanguage(settings.language || i18n.language)
}, [props.profile?.setting, i18n.language])
const [currentLanguage, setCurrentLanguage] = useState(savedLanguage)
@@ -75,7 +61,7 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
const handleLanguageChange = async (language: string | null) => {
if (!language) return
const nextLanguage = normalizeLanguage(language)
const nextLanguage = normalizeInterfaceLanguage(language)
if (nextLanguage === currentLanguage) return
const previousLanguage = currentLanguage
@@ -132,8 +118,8 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
<div className='flex items-center gap-2 sm:min-w-48'>
<Select
items={[
...LANGUAGE_OPTIONS.map((language) => ({
value: language.value,
...INTERFACE_LANGUAGE_OPTIONS.map((language) => ({
value: language.code,
label: language.label,
})),
]}
@@ -146,8 +132,8 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{LANGUAGE_OPTIONS.map((language) => (
<SelectItem key={language.value} value={language.value}>
{INTERFACE_LANGUAGE_OPTIONS.map((language) => (
<SelectItem key={language.code} value={language.code}>
{language.label}
</SelectItem>
))}
@@ -69,7 +69,7 @@ export function ProfileSettingsCard({
icon={<Settings className='h-4 w-4' />}
>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className='grid h-10 w-full grid-cols-2 items-stretch gap-1 rounded-xl p-1'>
<TabsList className='grid group-data-horizontal/tabs:h-10 w-full grid-cols-2 items-stretch gap-1 rounded-xl p-1'>
<TabsTrigger
value='bindings'
className='h-full gap-2 rounded-lg px-3 py-0 leading-none'
@@ -173,6 +173,7 @@ export function RedemptionsTable() {
columnId: 'status',
title: t('Status'),
options: redemptionStatusOptions,
singleSelect: true,
},
],
}}
@@ -23,6 +23,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { Plus, Edit, Trash2, Save } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { getBgColorClass } from '@/lib/colors'
import {
AlertDialog,
AlertDialogAction,
@@ -98,20 +99,20 @@ const createApiInfoSchema = (t: (key: string) => string) =>
type ApiInfoFormValues = z.infer<ReturnType<typeof createApiInfoSchema>>
const colorOptions = [
{ value: 'blue', label: 'Blue', bgClass: 'bg-blue-500' },
{ value: 'green', label: 'Green', bgClass: 'bg-green-500' },
{ value: 'cyan', label: 'Cyan', bgClass: 'bg-cyan-500' },
{ value: 'purple', label: 'Purple', bgClass: 'bg-purple-500' },
{ value: 'pink', label: 'Pink', bgClass: 'bg-pink-500' },
{ value: 'red', label: 'Red', bgClass: 'bg-red-500' },
{ value: 'orange', label: 'Orange', bgClass: 'bg-orange-500' },
{ value: 'amber', label: 'Amber', bgClass: 'bg-amber-500' },
{ value: 'yellow', label: 'Yellow', bgClass: 'bg-yellow-500' },
{ value: 'lime', label: 'Lime', bgClass: 'bg-lime-500' },
{ value: 'teal', label: 'Teal', bgClass: 'bg-teal-500' },
{ value: 'indigo', label: 'Indigo', bgClass: 'bg-indigo-500' },
{ value: 'violet', label: 'Violet', bgClass: 'bg-violet-500' },
{ value: 'slate', label: 'Slate', bgClass: 'bg-slate-500' },
{ value: 'blue', label: 'Blue' },
{ value: 'green', label: 'Green' },
{ value: 'cyan', label: 'Cyan' },
{ value: 'purple', label: 'Purple' },
{ value: 'pink', label: 'Pink' },
{ value: 'red', label: 'Red' },
{ value: 'orange', label: 'Orange' },
{ value: 'amber', label: 'Amber' },
{ value: 'yellow', label: 'Yellow' },
{ value: 'lime', label: 'Lime' },
{ value: 'teal', label: 'Teal' },
{ value: 'indigo', label: 'Indigo' },
{ value: 'violet', label: 'Violet' },
{ value: 'slate', label: 'Slate' },
]
export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
@@ -249,12 +250,13 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
const handleSaveAll = async () => {
try {
await updateOption.mutateAsync({
const result = await updateOption.mutateAsync({
key: 'console_setting.api_info',
value: JSON.stringify(apiInfoList),
})
setHasChanges(false)
toast.success(t('API info saved successfully'))
if (result.success) {
setHasChanges(false)
}
} catch {
toast.error(t('Failed to save API info'))
}
@@ -270,11 +272,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
)
}
const getColorClass = (color: string) => {
return (
colorOptions.find((opt) => opt.value === color)?.bgClass || 'bg-blue-500'
)
}
const getColorClass = (color: string) => getBgColorClass(color)
return (
<SettingsSection
@@ -488,7 +486,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
label: (
<div className='flex items-center gap-2'>
<div
className={`h-4 w-4 rounded-full ${option.bgClass}`}
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
/>
{option.label}
</div>
@@ -509,7 +507,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
<SelectItem key={option.value} value={option.value}>
<div className='flex items-center gap-2'>
<div
className={`h-4 w-4 rounded-full ${option.bgClass}`}
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
/>
{option.label}
</div>
@@ -151,7 +151,7 @@ export function DashboardSection({ defaultValues }: DashboardSectionProps) {
items={[
...granularityOptions.map((option) => ({
value: option.value,
label: option.label,
label: t(option.label),
})),
]}
onValueChange={field.onChange}
@@ -167,7 +167,7 @@ export function DashboardSection({ defaultValues }: DashboardSectionProps) {
<SelectGroup>
{granularityOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
@@ -50,7 +50,12 @@ import { Textarea } from '@/components/ui/textarea'
import { RULE_TEMPLATES } from './constants'
import type { AffinityRule, KeySource } from './types'
const KEY_SOURCE_TYPES = ['context_int', 'context_string', 'gjson'] as const
const KEY_SOURCE_TYPES = [
'context_int',
'context_string',
'request_header',
'gjson',
] as const
const CONTEXT_KEY_PRESETS = [
'id',
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export interface KeySource {
type: 'context_int' | 'context_string' | 'gjson'
type: 'context_int' | 'context_string' | 'request_header' | 'gjson'
key?: string
path?: string
}
@@ -431,9 +431,22 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
{sensitiveVisible ? getUserAvatarFallback(log.username) : '•'}
</AvatarFallback>
</Avatar>
<span className='text-muted-foreground truncate text-sm hover:underline'>
{sensitiveVisible ? log.username : '••••'}
</span>
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<span className='text-muted-foreground max-w-[100px] truncate text-sm hover:underline' />
}
>
{sensitiveVisible ? log.username : '••••'}
</TooltipTrigger>
{sensitiveVisible && log.username.length > 12 && (
<TooltipContent side='top'>
{log.username}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</button>
)
},
@@ -468,15 +481,30 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
if (groupRatioText) metaParts.push(groupRatioText)
return (
<div className='flex max-w-[150px] flex-col gap-0.5'>
<StatusBadge
label={displayName}
icon={KeyRound}
copyText={sensitiveVisible ? tokenName : undefined}
size='sm'
showDot={false}
className='border-border/60 bg-muted/30 text-foreground max-w-full overflow-hidden rounded-md border px-1.5 py-0.5 font-mono'
/>
<div className='flex max-w-[200px] flex-col gap-0.5'>
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<div className='max-w-full' />
}
>
<StatusBadge
label={displayName}
icon={KeyRound}
copyText={sensitiveVisible ? tokenName : undefined}
size='sm'
showDot={false}
className='border-border/60 bg-muted/30 text-foreground max-w-full overflow-hidden rounded-md border px-1.5 py-0.5 font-mono'
/>
</TooltipTrigger>
{sensitiveVisible && tokenName.length > 16 && (
<TooltipContent side='top' className='max-w-xs break-all'>
{tokenName}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
{metaParts.length > 0 && (
<span className='text-muted-foreground/60 truncate text-[11px]'>
{metaParts.join(' · ')}
@@ -486,7 +514,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
)
},
meta: { label: t('Token') },
size: 130,
size: 160,
})
columns.push(
@@ -985,9 +985,8 @@ export function DetailsDialog(props: DetailsDialogProps) {
</DetailSection>
)}
{/* Param override (admin only) */}
{props.isAdmin &&
other?.po &&
{/* Param override */}
{other?.po &&
Array.isArray(other.po) &&
other.po.length > 0 && (
<DetailSection
@@ -150,6 +150,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
manualPagination: true,
manualFiltering: true,
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
})
+1 -1
View File
@@ -127,7 +127,7 @@ function UsageLogsContent() {
<div className='space-y-4'>
{showTaskSwitcher && (
<Tabs value={activeCategory} onValueChange={handleSectionChange}>
<TabsList className='h-auto max-w-full flex-wrap justify-start'>
<TabsList className='group-data-horizontal/tabs:h-auto max-w-full flex-wrap justify-start'>
{visibleSections.map((section) => (
<TabsTrigger key={section} value={section}>
{t(SECTION_META[section].titleKey)}
@@ -121,6 +121,17 @@ export function UsersMutateDrawer({
const currentQuotaRaw = form.watch('quota_dollars') || 0
const onSubmit = async (data: UserFormValues) => {
if (!isUpdate) {
const passwordLength = data.password?.length || 0
if (passwordLength < 8 || passwordLength > 20) {
form.setError('password', {
type: 'manual',
message: t('Password must be between 8 and 20 characters'),
})
return
}
}
setIsSubmitting(true)
try {
const payload = transformFormDataToPayload(data, currentRow?.id)
@@ -187,11 +187,13 @@ export function UsersTable() {
columnId: 'status',
title: t('Status'),
options: getUserStatusOptions(t),
singleSelect: true,
},
{
columnId: 'role',
title: t('Role'),
options: getUserRoleOptions(t),
singleSelect: true,
},
],
}}
@@ -240,7 +240,7 @@ export function RechargeFormCard({
className={cn(
'hover:border-foreground flex min-h-16 flex-col items-start rounded-lg px-3 py-2.5 text-left whitespace-normal sm:min-h-[72px] sm:p-4',
selectedPreset === preset.value
? 'border-foreground bg-foreground/5'
? 'border-foreground bg-foreground/5 dark:border-foreground dark:bg-foreground/10'
: 'border-muted'
)}
onClick={() => onSelectPreset(preset)}
+1 -1
View File
@@ -25,5 +25,5 @@ For commercial licensing, please contact support@quantumnous.com
*/
export function generateAffiliateLink(affCode: string): string {
if (typeof window === 'undefined') return ''
return `${window.location.origin}/register?aff=${affCode}`
return `${window.location.origin}/sign-up?aff=${affCode}`
}
+10 -69
View File
@@ -20,77 +20,16 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { useStatus } from '@/hooks/use-status'
import { parseHeaderNavModulesFromStatus } from '@/lib/nav-modules'
export type TopNavLink = {
title: string
href: string
disabled?: boolean
requiresAuth?: boolean
external?: boolean
}
// Default navigation configuration
const DEFAULT_HEADER_NAV_MODULES = {
home: true,
console: true,
pricing: { enabled: true, requireAuth: false },
rankings: { enabled: true, requireAuth: false },
docs: true,
about: true,
}
function parseAccessModule(
raw: unknown,
fallback: { enabled: boolean; requireAuth: boolean }
) {
if (
typeof raw === 'boolean' ||
typeof raw === 'string' ||
typeof raw === 'number'
) {
return {
enabled: raw === true || raw === 'true' || raw === '1' || raw === 1,
requireAuth: fallback.requireAuth,
}
}
if (raw && typeof raw === 'object') {
const record = raw as Record<string, unknown>
return {
enabled:
typeof record.enabled === 'boolean' ? record.enabled : fallback.enabled,
requireAuth:
typeof record.requireAuth === 'boolean'
? record.requireAuth
: fallback.requireAuth,
}
}
return { ...fallback }
}
function parseHeaderNavModules(
raw: unknown
): typeof DEFAULT_HEADER_NAV_MODULES {
if (!raw || String(raw).trim() === '') {
return DEFAULT_HEADER_NAV_MODULES
}
try {
const parsed = JSON.parse(String(raw)) as Record<string, unknown>
return {
...DEFAULT_HEADER_NAV_MODULES,
...parsed,
pricing: parseAccessModule(
parsed.pricing,
DEFAULT_HEADER_NAV_MODULES.pricing
),
rankings: parseAccessModule(
parsed.rankings,
DEFAULT_HEADER_NAV_MODULES.rankings
),
}
} catch {
return DEFAULT_HEADER_NAV_MODULES
}
}
/**
* Generate top navigation links based on HeaderNavModules configuration from backend /api/status
* Backend format example (stringified JSON):
@@ -110,8 +49,10 @@ export function useTopNavLinks(): TopNavLink[] {
// Parse HeaderNavModules
const modules = useMemo(() => {
return parseHeaderNavModules(status?.HeaderNavModules)
}, [status?.HeaderNavModules])
return parseHeaderNavModulesFromStatus(
status as Record<string, unknown> | null
)
}, [status])
// Documentation link (may be external)
const docsLink: string | undefined = status?.docs_link as string | undefined
@@ -133,15 +74,15 @@ export function useTopNavLinks(): TopNavLink[] {
// Pricing
const pricing = modules?.pricing
if (pricing && typeof pricing === 'object' && pricing.enabled) {
const disabled = pricing.requireAuth && !isAuthed
links.push({ title: t('Model Square'), href: '/pricing', disabled })
const requiresAuth = pricing.requireAuth && !isAuthed
links.push({ title: t('Model Square'), href: '/pricing', requiresAuth })
}
// Rankings
const rankings = modules?.rankings
if (rankings && typeof rankings === 'object' && rankings.enabled) {
const disabled = rankings.requireAuth && !isAuthed
links.push({ title: t('Rankings'), href: '/rankings', disabled })
const requiresAuth = rankings.requireAuth && !isAuthed
links.push({ title: t('Rankings'), href: '/rankings', requiresAuth })
}
// Docs (supports external links)
+41
View File
@@ -0,0 +1,41 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export const INTERFACE_LANGUAGE_OPTIONS = [
{ code: 'zh', label: '简体中文' },
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'Français' },
{ code: 'ru', label: 'Русский' },
{ code: 'ja', label: '日本語' },
{ code: 'vi', label: 'Tiếng Việt' },
] as const
export type InterfaceLanguageCode =
(typeof INTERFACE_LANGUAGE_OPTIONS)[number]['code']
export function normalizeInterfaceLanguage(value?: string | null): string {
if (!value) return 'en'
const normalized = value.trim().replace(/_/g, '-').toLowerCase()
if (normalized.startsWith('zh')) return 'zh'
return INTERFACE_LANGUAGE_OPTIONS.some((lang) => lang.code === normalized)
? normalized
: 'en'
}
+4 -4
View File
@@ -11,25 +11,25 @@
"file": "fr.json",
"missingCount": 0,
"extrasCount": 0,
"untranslatedCount": 1
"untranslatedCount": 21
},
"ja": {
"file": "ja.json",
"missingCount": 0,
"extrasCount": 0,
"untranslatedCount": 92
"untranslatedCount": 120
},
"ru": {
"file": "ru.json",
"missingCount": 0,
"extrasCount": 0,
"untranslatedCount": 107
"untranslatedCount": 135
},
"vi": {
"file": "vi.json",
"missingCount": 0,
"extrasCount": 0,
"untranslatedCount": 3
"untranslatedCount": 23
},
"zh": {
"file": "zh.json",

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