Files
new-api/controller/channel_test_internal_test.go
Calcium-Ion 128089fa7d feat(i18n): translate web console messages in the frontend and AI-client errors in the backend (#7712)
* feat(i18n): translate web console messages in the frontend and AI-client errors in the backend

Chinese text was hard-coded in API messages, stored log content, emails and
server logs, so it reached users of every interface language.

Text is now translated where its reader is:

- Web console APIs return the English source text as message_key with
  message_params, and the web console translates it in all seven languages.
  message keeps the English text for other API callers.
- Errors returned to AI clients, emails and notifications are translated by
  the backend (en, zh-CN, zh-TW) in the language saved on the account, then
  the language of the request's Accept-Language header.
- A reader who states no language still gets the original Chinese text of the
  messages that were Chinese before, word for word. Gateways that use another
  new-api as their upstream match that text, for example to disable a channel
  whose account ran out of quota.
- Usage, top-up and system log content is stored as content_parts (key and
  params) and rendered in the viewer's language. Task fail reasons and refund
  reasons written by the gateway are English keys. Rows written earlier are
  shown as before.
- Server logs are English.

Also:

- The saved interface language zhTW, and zh-HK, zh-MO and zh-Hant, now select
  Traditional Chinese in the backend instead of Simplified Chinese.
- The built-in group descriptions are translated; descriptions an
  administrator saved are shown as written.
- .agents/rules/i18n.md records the conventions. Tests in i18n/ fail on a
  non-English string literal, on a web console message without translations
  and on a locale key without a constant.

* feat(i18n): print server logs in Chinese when LOG_LANGUAGE asks for it

Server log lines are English by default. With LOG_LANGUAGE=zh-CN, the lines
that were Chinese before print their original Chinese text.

common.LogText formats a log line from its English format, which is also the
key of its Chinese text in common/log_text.zh-CN.json. A line without an entry
prints in English. go vet checks the arguments of every call, and
TestServerLogTexts checks that each entry still matches a call and has the
same verbs as its key.

* docs: list LOG_LANGUAGE in the docker compose example

* feat(i18n): translate every server log line and add DEFAULT_LANGUAGE

Every line the gateway prints goes through common.LogText. With
DEFAULT_LANGUAGE=zh-CN, the lines that have a Chinese text in
common/log_text.zh-CN.json print it and the others print English.

DEFAULT_LANGUAGE replaces LOG_LANGUAGE. It also sets the language of
backend messages, emails and notifications for a reader who states no
language: en, zh-CN or zh-TW, English when unset. The list of messages
that stayed Chinese for such readers is removed.

Review fixes:
- Channel test logs store the token name "Model test"; the console shows
  it in the interface language.
- Task fail reasons written by the gateway are fixed sentences the console
  translates. The HTTP status and the upstream detail go to the server log
  when a polled task is failed.
- The auto-disable reason of a slow channel is written in DEFAULT_LANGUAGE.

Also:
- Drop the repository-scanning lint tests, the i18n rules file and its
  read gate. AGENTS.md keeps the conventions in four lines.
- Payment error toasts keep their previous text and only add the
  translated message key.
- Remove guards and helpers that no caller needs.

* fix(i18n): address review on language lookup, notices and error responses

- Relay requests no longer load the user a second time to find the
  language: the settings TokenAuth put into the request are used as they
  are, also when they hold no language.
- Upstream model update notices are rendered in each recipient's saved
  language, DEFAULT_LANGUAGE when none.
- /api/usage/token and /api/log/token answer with backend-translated
  messages again, with the keys and status codes used before this PR.
  Their callers are scripts, not the web console.
- Webhook notifications fill {{value}} placeholders like the other
  senders. Running the rendered text through Sprintf appended
  %!(EXTRA ...) and broke %2F in links.
- RootUserLanguage reads the setting of the root user it already loaded.
- zh_TW and zh_HK are read as Traditional Chinese.
- Add common.ApiErrorStatus for error responses under another HTTP status
  and use it where those responses were built by hand.
- Remove middleware.GetLanguage, which had no callers.
2026-10-09 21:39:51 +08:00

496 lines
16 KiB
Go

package controller
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetChannelDefaultBaseURLsUsesBuiltInDefaults(t *testing.T) {
originalBaseURLs := constant.ChannelBaseURLs
constant.ChannelBaseURLs = append([]string(nil), originalBaseURLs...)
constant.ChannelBaseURLs[constant.ChannelTypeDeepSeek] = "https://deepseek.server.example"
t.Cleanup(func() {
constant.ChannelBaseURLs = originalBaseURLs
})
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/channel/default_base_urls", nil)
GetChannelDefaultBaseURLs(c)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"`
Data map[int]string `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
assert.Equal(t, "https://deepseek.server.example", response.Data[constant.ChannelTypeDeepSeek])
assert.Equal(t, "https://api.openai.com", response.Data[constant.ChannelTypeOpenAI])
assert.NotContains(t, response.Data, constant.ChannelTypeAzure)
assert.NotContains(t, response.Data, constant.ChannelTypeNewAPI)
assert.NotContains(t, response.Data, constant.ChannelTypeTaskPlugin)
}
func TestValidateChannelProxy(t *testing.T) {
tests := []struct {
name string
proxy string
wantErr bool
}{
{name: "empty"},
{name: "http", proxy: "http://proxy.example:8080"},
{name: "https", proxy: "https://proxy.example:8443"},
{name: "socks5", proxy: "socks5://proxy.example"},
{name: "socks5h", proxy: "socks5h://proxy.example:1080/"},
{name: "unsupported", proxy: "ftp://proxy.example", wantErr: true},
{name: "path", proxy: "socks5://proxy.example:1080/path", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
setting, err := common.Marshal(dto.ChannelSettings{Proxy: test.proxy})
require.NoError(t, err)
channel := &model.Channel{
Type: constant.ChannelTypeOpenAI,
Setting: common.GetPointer(string(setting)),
}
err = validateChannel(channel, false)
if test.wantErr {
require.ErrorContains(t, err, "invalid channel proxy")
return
}
require.NoError(t, err)
})
}
}
func TestValidateChannelRequiresNewAPIBaseURL(t *testing.T) {
tests := []struct {
name string
baseURL *string
wantErr bool
}{
{name: "missing", wantErr: true},
{name: "blank", baseURL: common.GetPointer(" "), wantErr: true},
{name: "configured", baseURL: common.GetPointer("https://new-api.example")},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
channel := &model.Channel{
Type: constant.ChannelTypeNewAPI,
BaseURL: test.baseURL,
}
err := validateChannel(channel, false)
if test.wantErr {
require.ErrorContains(t, err, "New API channel base URL cannot be empty")
return
}
require.NoError(t, err)
})
}
}
func TestNewAPIChannelRegistration(t *testing.T) {
apiType, ok := common.ChannelType2APIType(constant.ChannelTypeNewAPI)
require.True(t, ok)
assert.Equal(t, constant.APITypeNewAPI, apiType)
assert.Equal(t, "New API", constant.GetChannelTypeName(constant.ChannelTypeNewAPI))
require.Greater(t, len(constant.ChannelBaseURLs), constant.ChannelTypeNewAPI)
assert.Empty(t, constant.ChannelBaseURLs[constant.ChannelTypeNewAPI])
}
func TestResponsesCompactChannelSupport(t *testing.T) {
tests := []struct {
name string
channelType int
apiType int
want bool
}{
{name: "OpenAI", channelType: constant.ChannelTypeOpenAI, apiType: constant.APITypeOpenAI, want: true},
{name: "Azure", channelType: constant.ChannelTypeAzure, apiType: constant.APITypeOpenAI, want: true},
{name: "Codex", channelType: constant.ChannelTypeCodex, apiType: constant.APITypeCodex, want: true},
{name: "Advanced Custom", channelType: constant.ChannelTypeAdvancedCustom, apiType: constant.APITypeAdvancedCustom, want: true},
{name: "Sub2API", channelType: constant.ChannelTypeSub2API, apiType: constant.APITypeSub2API, want: true},
{name: "New API", channelType: constant.ChannelTypeNewAPI, apiType: constant.APITypeNewAPI, want: true},
{name: "Anthropic", channelType: constant.ChannelTypeAnthropic, apiType: constant.APITypeAnthropic, want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, common.SupportsResponsesCompact(test.channelType, test.apiType))
})
}
}
func TestMultiprotocolGatewayEndpointTypes(t *testing.T) {
want := []constant.EndpointType{
constant.EndpointTypeOpenAI,
constant.EndpointTypeOpenAIResponse,
constant.EndpointTypeOpenAIResponseCompact,
constant.EndpointTypeAnthropic,
constant.EndpointTypeGemini,
constant.EndpointTypeOpenAIAlphaSearch,
}
assert.Equal(t, want, common.GetEndpointTypesByChannelType(constant.ChannelTypeNewAPI, "gpt-5"))
assert.Equal(t, want, common.GetEndpointTypesByChannelType(constant.ChannelTypeSub2API, "gpt-5"))
}
func TestCopyChannelRejectsInvalidLegacyProxySettings(t *testing.T) {
db := setupModelListControllerTestDB(t)
settingBytes, err := common.Marshal(dto.ChannelSettings{
Proxy: "socks5://proxy.example/legacy-path",
})
require.NoError(t, err)
setting := string(settingBytes)
origin := &model.Channel{
Type: constant.ChannelTypeOpenAI,
Name: "legacy proxy channel",
Key: "test-key",
Models: "gpt-test",
Group: "default",
Setting: &setting,
}
require.NoError(t, db.Create(origin).Error)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", origin.Id)}}
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/copy", nil)
CopyChannel(ctx)
assert.Contains(t, recorder.Body.String(), "invalid channel settings")
var channelCount int64
require.NoError(t, db.Model(&model.Channel{}).Count(&channelCount).Error)
assert.Equal(t, int64(1), channelCount)
}
func TestDeleteChannelResetsProxyCacheWhenPreReadFails(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{}))
service.ResetProxyClientCache()
t.Cleanup(service.ResetProxyClientCache)
proxyURL := "http://proxy.example:8080"
beforeDelete, err := service.GetHttpClientWithProxy(proxyURL)
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Params = gin.Params{{Key: "id", Value: "999999"}}
ctx.Request = httptest.NewRequest(http.MethodDelete, "/api/channel/999999", nil)
DeleteChannel(ctx)
assert.Contains(t, recorder.Body.String(), `"success":true`)
afterDelete, err := service.GetHttpClientWithProxy(proxyURL)
require.NoError(t, err)
assert.NotSame(t, beforeDelete, afterDelete)
}
func TestDeleteChannelBatchReportsAndAuditsActualDeletedCount(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{}))
channel := &model.Channel{Name: "existing", Key: "test-key"}
require.NoError(t, db.Create(channel).Error)
requestBody, err := common.Marshal(ChannelBatch{Ids: []int{channel.Id, 999999}})
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodDelete, "/api/channel/batch", bytes.NewReader(requestBody))
ctx.Request.Header.Set("Content-Type", "application/json")
DeleteChannelBatch(ctx)
var response struct {
Success bool `json:"success"`
Data int64 `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.True(t, response.Success)
assert.Equal(t, int64(1), response.Data)
var auditLog model.AuditLog
require.NoError(t, db.Order("id desc").First(&auditLog).Error)
var auditData struct {
Operation struct {
Params map[string]any `json:"params"`
} `json:"op"`
}
encodedAudit, err := common.Marshal(auditLog.Other)
require.NoError(t, err)
require.NoError(t, common.Unmarshal(encodedAudit, &auditData))
assert.Equal(t, float64(1), auditData.Operation.Params["count"])
}
func TestSettleTestQuotaUsesTieredBilling(t *testing.T) {
info := &relaycommon.RelayInfo{
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: `param("stream") == true ? tier("stream", p * 3) : tier("base", p * 2)`,
ExprHash: billingexpr.ExprHashString(`param("stream") == true ? tier("stream", p * 3) : tier("base", p * 2)`),
GroupRatio: 1,
EstimatedTier: "stream",
QuotaPerUnit: common.QuotaPerUnit,
ExprVersion: 1,
},
BillingRequestInput: &billingexpr.RequestInput{
Body: []byte(`{"stream":true}`),
},
}
quota, result := settleTestQuota(info, types.PriceData{
ModelRatio: 1,
CompletionRatio: 2,
}, &dto.Usage{
PromptTokens: 1000,
})
require.Equal(t, 1500, quota)
require.NotNil(t, result)
require.Equal(t, "stream", result.MatchedTier)
}
func TestBuildTestLogOtherInjectsTieredInfo(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
info := &relaycommon.RelayInfo{
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: `tier("base", p * 2)`,
},
ChannelMeta: &relaycommon.ChannelMeta{},
}
priceData := types.PriceData{
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
}
usage := &dto.Usage{
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 12,
},
}
requestRules := []billingexpr.RequestRuleTrace{{
Cond: `param("service_tier") == "fast"`,
Multiplier: 2,
Matched: true,
}}
other := buildTestLogOther(ctx, info, priceData, usage, &billingexpr.TieredResult{
MatchedTier: "base",
RequestRules: requestRules,
})
fields := other.Snapshot()
require.Equal(t, "tiered_expr", fields["billing_mode"])
require.Equal(t, "base", fields["matched_tier"])
require.Equal(t, requestRules, fields["request_rules"])
require.NotEmpty(t, fields["expr_b64"])
}
func TestResolveChannelTestUserIDUsesRequestUser(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set("id", 2)
userID, err := resolveChannelTestUserID(ctx)
require.NoError(t, err)
require.Equal(t, 2, userID)
}
func TestSelectChannelsForAutomaticTestPassiveRecoveryOnlyUsesAutoDisabled(t *testing.T) {
channels := []*model.Channel{
{Id: 1, Status: common.ChannelStatusEnabled},
{Id: 2, Status: common.ChannelStatusAutoDisabled},
{Id: 3, Status: common.ChannelStatusManuallyDisabled},
}
selected := selectChannelsForAutomaticTest(channels, operation_setting.ChannelTestModePassiveRecovery)
require.Len(t, selected, 1)
require.Equal(t, 2, selected[0].Id)
}
func TestSelectChannelsForAutomaticTestScheduledSkipsManualDisabled(t *testing.T) {
channels := []*model.Channel{
{Id: 1, Status: common.ChannelStatusEnabled},
{Id: 2, Status: common.ChannelStatusAutoDisabled},
{Id: 3, Status: common.ChannelStatusManuallyDisabled},
}
selected := selectChannelsForAutomaticTest(channels, operation_setting.ChannelTestModeScheduledAll)
require.Len(t, selected, 2)
require.Equal(t, 1, selected[0].Id)
require.Equal(t, 2, selected[1].Id)
}
func TestSelectChannelsForAutomaticTestAutoBanOnlyUsesEligibleChannels(t *testing.T) {
autoBanEnabled := 1
autoBanDisabled := 0
channels := []*model.Channel{
{Id: 1, Status: common.ChannelStatusEnabled, AutoBan: &autoBanEnabled},
{Id: 2, Status: common.ChannelStatusEnabled, AutoBan: &autoBanDisabled},
{Id: 3, Status: common.ChannelStatusAutoDisabled, AutoBan: &autoBanEnabled},
{Id: 4, Status: common.ChannelStatusManuallyDisabled, AutoBan: &autoBanEnabled},
{Id: 5, Status: common.ChannelStatusEnabled},
}
selected := selectChannelsForAutomaticTest(channels, operation_setting.ChannelTestModeAutoBanOnly)
require.Len(t, selected, 2)
require.Equal(t, 1, selected[0].Id)
require.Equal(t, 3, selected[1].Id)
}
func TestRunChannelTestWorkersHonorsConfiguredConcurrency(t *testing.T) {
originalInterval := common.RequestInterval
common.RequestInterval = 0
t.Cleanup(func() { common.RequestInterval = originalInterval })
channels := []*model.Channel{
{Id: 1, Status: common.ChannelStatusEnabled},
{Id: 2, Status: common.ChannelStatusEnabled},
{Id: 3, Status: common.ChannelStatusEnabled},
{Id: 4, Status: common.ChannelStatusEnabled},
}
started := make(chan struct{}, len(channels))
release := make(chan struct{})
var active atomic.Int32
var maxActive atomic.Int32
progress := make([]int, 0, len(channels)+1)
summaryResult := make(chan channelTestSummary, 1)
go func() {
summaryResult <- runChannelTestWorkers(
context.Background(),
channels,
2,
func(_ context.Context, _ *model.Channel) channelTestSummary {
current := active.Add(1)
defer active.Add(-1)
for {
observed := maxActive.Load()
if current <= observed || maxActive.CompareAndSwap(observed, current) {
break
}
}
started <- struct{}{}
<-release
return channelTestSummary{Tested: 1, Succeeded: 1}
},
func(processed, _ int) {
progress = append(progress, processed)
},
)
}()
<-started
<-started
select {
case <-started:
t.Fatal("started more channel tests than the configured concurrency")
default:
}
close(release)
summary := <-summaryResult
assert.Equal(t, int32(2), maxActive.Load())
assert.Equal(t, channelTestSummary{Tested: 4, Succeeded: 4}, summary)
assert.Equal(t, []int{0, 1, 2, 3, 4}, progress)
}
func TestRunChannelTestWorkersStopsAfterCancellation(t *testing.T) {
originalInterval := common.RequestInterval
common.RequestInterval = 0
t.Cleanup(func() { common.RequestInterval = originalInterval })
ctx, cancel := context.WithCancel(context.Background())
channels := []*model.Channel{
{Id: 1, Status: common.ChannelStatusEnabled},
{Id: 2, Status: common.ChannelStatusEnabled},
{Id: 3, Status: common.ChannelStatusEnabled},
{Id: 4, Status: common.ChannelStatusEnabled},
}
started := make(chan struct{}, len(channels))
progress := make([]int, 0, 1)
summaryResult := make(chan channelTestSummary, 1)
go func() {
summaryResult <- runChannelTestWorkers(
ctx,
channels,
2,
func(ctx context.Context, _ *model.Channel) channelTestSummary {
started <- struct{}{}
<-ctx.Done()
return channelTestSummary{Tested: 1, Succeeded: 1}
},
func(processed, _ int) {
progress = append(progress, processed)
},
)
}()
<-started
<-started
cancel()
summary := <-summaryResult
select {
case <-started:
t.Fatal("started another channel test after cancellation")
default:
}
assert.Equal(t, channelTestSummary{Tested: 2, Succeeded: 2}, summary)
assert.Equal(t, []int{0}, progress)
}
func TestTestAllChannelsRejectsExistingActiveTask(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.SystemTask{}, &model.SystemTaskLock{}))
existing, err := model.CreateSystemTask(model.SystemTaskTypeChannelTest, nil, nil)
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/test", nil)
TestAllChannels(ctx)
require.Equal(t, http.StatusConflict, recorder.Code)
require.Contains(t, recorder.Body.String(), existing.TaskID)
require.Contains(t, recorder.Body.String(), "A channel test task is already running or queued")
}