mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
* 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.
245 lines
10 KiB
Go
245 lines
10 KiB
Go
package common
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/constant"
|
|
)
|
|
|
|
var (
|
|
Port = flag.Int("port", 3000, "the listening port")
|
|
PrintVersion = flag.Bool("version", false, "print version and exit")
|
|
PrintHelp = flag.Bool("help", false, "print help and exit")
|
|
LogDir = flag.String("log-dir", "./logs", "specify the log directory")
|
|
)
|
|
|
|
func printHelp() {
|
|
fmt.Println("NewAPI(Based OneAPI) " + Version + " - The next-generation LLM gateway and AI asset management system supports multiple languages.")
|
|
fmt.Println("Original Project: OneAPI by JustSong - https://github.com/songquanpeng/one-api")
|
|
fmt.Println("Maintainer: QuantumNous - https://github.com/QuantumNous/new-api")
|
|
fmt.Println("Usage: newapi [--port <port>] [--log-dir <log directory>] [--version] [--help]")
|
|
}
|
|
|
|
func InitEnv() {
|
|
flag.Parse()
|
|
|
|
envVersion := os.Getenv("VERSION")
|
|
if envVersion != "" {
|
|
Version = envVersion
|
|
}
|
|
|
|
if *PrintVersion {
|
|
fmt.Println(Version)
|
|
os.Exit(0)
|
|
}
|
|
|
|
if *PrintHelp {
|
|
printHelp()
|
|
os.Exit(0)
|
|
}
|
|
|
|
DefaultLanguage = os.Getenv("DEFAULT_LANGUAGE")
|
|
SetLogLanguage(DefaultLanguage)
|
|
|
|
if os.Getenv("SESSION_SECRET") != "" {
|
|
ss := os.Getenv("SESSION_SECRET")
|
|
if ss == "random_string" {
|
|
log.Println(LogText("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string."))
|
|
log.Fatal(LogText("Please set SESSION_SECRET to a random string."))
|
|
} else {
|
|
SessionSecret = ss
|
|
}
|
|
}
|
|
if os.Getenv("CRYPTO_SECRET") != "" {
|
|
CryptoSecret = os.Getenv("CRYPTO_SECRET")
|
|
} else {
|
|
CryptoSecret = SessionSecret
|
|
}
|
|
if err := InitSessionCookieSettings(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
initUserSessionSettings()
|
|
if os.Getenv("SQLITE_PATH") != "" {
|
|
SQLitePath = os.Getenv("SQLITE_PATH")
|
|
}
|
|
if *LogDir != "" {
|
|
var err error
|
|
*LogDir, err = filepath.Abs(*LogDir)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(*LogDir); os.IsNotExist(err) {
|
|
err = os.Mkdir(*LogDir, 0777)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize variables from constants.go that were using environment variables
|
|
DebugEnabled = os.Getenv("DEBUG") == "true"
|
|
MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true"
|
|
IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
|
|
PasswordLoginEncryptionEnabled = GetEnvOrDefaultBool("PASSWORD_LOGIN_ENCRYPTION_ENABLED", false)
|
|
initNodeNameIdentity()
|
|
TLSInsecureSkipVerify = GetEnvOrDefaultBool("TLS_INSECURE_SKIP_VERIFY", false)
|
|
if TLSInsecureSkipVerify {
|
|
if tr, ok := http.DefaultTransport.(*http.Transport); ok && tr != nil {
|
|
if tr.TLSClientConfig != nil {
|
|
tr.TLSClientConfig.InsecureSkipVerify = true
|
|
} else {
|
|
tr.TLSClientConfig = InsecureTLSConfig.Clone()
|
|
}
|
|
}
|
|
}
|
|
SMTPStartTLSEnabled = GetEnvOrDefaultBool("SMTP_STARTTLS_ENABLE", GetEnvOrDefaultBool("SMTP_STARTTLS_ENABLED", false))
|
|
SMTPInsecureSkipVerify = GetEnvOrDefaultBool("SMTP_INSECURE_SKIP_VERIFY", GetEnvOrDefaultBool("SMTP_TLS_INSECURE_SKIP_VERIFY", false))
|
|
|
|
// Parse requestInterval and set RequestInterval
|
|
requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL"))
|
|
RequestInterval = time.Duration(requestInterval) * time.Second
|
|
|
|
// Initialize variables with GetEnvOrDefault
|
|
SyncFrequency = GetEnvOrDefault("SYNC_FREQUENCY", 60)
|
|
BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5)
|
|
RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0)
|
|
RelayIdleConnTimeout = GetEnvOrDefault("RELAY_IDLE_CONN_TIMEOUT", 90)
|
|
RelayResponseHeaderTimeout = GetEnvOrDefault("RELAY_RESPONSE_HEADER_TIMEOUT", 1800)
|
|
RelayMaxIdleConns = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS", 500)
|
|
RelayMaxIdleConnsPerHost = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS_PER_HOST", 100)
|
|
|
|
// Initialize string variables with GetEnvOrDefaultString
|
|
GeminiSafetySetting = GetEnvOrDefaultString("GEMINI_SAFETY_SETTING", "BLOCK_NONE")
|
|
CohereSafetySetting = GetEnvOrDefaultString("COHERE_SAFETY_SETTING", "NONE")
|
|
|
|
// Initialize rate limit variables
|
|
GlobalApiRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_API_RATE_LIMIT_ENABLE", true)
|
|
GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 360)
|
|
GlobalApiRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_API_RATE_LIMIT_DURATION", 180))
|
|
|
|
GlobalWebRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_WEB_RATE_LIMIT_ENABLE", true)
|
|
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 120)
|
|
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
|
|
|
|
GlobalStaticRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_STATIC_RATE_LIMIT_ENABLE", false)
|
|
GlobalStaticRateLimitNum = GetEnvOrDefault("GLOBAL_STATIC_RATE_LIMIT", 1000)
|
|
GlobalStaticRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_STATIC_RATE_LIMIT_DURATION", 180))
|
|
|
|
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
|
|
CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20)
|
|
CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60))
|
|
|
|
SearchRateLimitEnable = GetEnvOrDefaultBool("SEARCH_RATE_LIMIT_ENABLE", true)
|
|
SearchRateLimitNum = GetEnvOrDefault("SEARCH_RATE_LIMIT", 10)
|
|
SearchRateLimitDuration = int64(GetEnvOrDefault("SEARCH_RATE_LIMIT_DURATION", 60))
|
|
initConstantEnv()
|
|
}
|
|
|
|
func initUserSessionSettings() {
|
|
UserSessionActiveLimit = positiveUserSessionEnv("USER_SESSION_ACTIVE_LIMIT", DefaultUserSessionActiveLimit)
|
|
UserSessionIssuanceLimit = positiveUserSessionEnv("USER_SESSION_ISSUANCE_LIMIT", DefaultUserSessionIssuanceLimit)
|
|
UserSessionIssuanceWindowSeconds = int64(positiveUserSessionEnv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", DefaultUserSessionIssuanceWindowSeconds))
|
|
UserSessionRevokedRetentionDays = positiveUserSessionEnv("USER_SESSION_REVOKED_RETENTION_DAYS", DefaultUserSessionRevokedRetentionDays)
|
|
UserSessionHourlyAlertThreshold = positiveUserSessionEnv("USER_SESSION_HOURLY_ALERT_THRESHOLD", DefaultUserSessionHourlyAlertThreshold)
|
|
|
|
const secondsPerDay = 24 * 60 * 60
|
|
if int64(UserSessionRevokedRetentionDays) > math.MaxInt64/secondsPerDay {
|
|
SysError(LogText(
|
|
"USER_SESSION_REVOKED_RETENTION_DAYS is too large, using default value: %d",
|
|
DefaultUserSessionRevokedRetentionDays,
|
|
))
|
|
UserSessionRevokedRetentionDays = DefaultUserSessionRevokedRetentionDays
|
|
}
|
|
retentionSeconds := int64(UserSessionRevokedRetentionDays) * secondsPerDay
|
|
if UserSessionIssuanceWindowSeconds > retentionSeconds {
|
|
configuredWindow := UserSessionIssuanceWindowSeconds
|
|
UserSessionIssuanceWindowSeconds = retentionSeconds
|
|
SysError(LogText(
|
|
"USER_SESSION_ISSUANCE_WINDOW_SECONDS exceeds revoked retention; configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d",
|
|
configuredWindow,
|
|
retentionSeconds,
|
|
UserSessionIssuanceWindowSeconds,
|
|
))
|
|
}
|
|
}
|
|
|
|
func positiveUserSessionEnv(name string, fallback int) int {
|
|
value := GetEnvOrDefault(name, fallback)
|
|
if value <= 0 {
|
|
SysError(LogText("%s must be positive, using default value: %d", name, fallback))
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func initConstantEnv() {
|
|
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
|
|
constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
|
|
constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 64)
|
|
constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 128)
|
|
// MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨
|
|
constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 128)
|
|
constant.AnonymousRequestBodyLimitKB = GetEnvOrDefault("ANONYMOUS_REQUEST_BODY_LIMIT_KB", 512)
|
|
// ForceStreamOption 覆盖请求参数,强制返回usage信息
|
|
constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true)
|
|
constant.CountToken = GetEnvOrDefaultBool("CountToken", true)
|
|
constant.GetMediaToken = GetEnvOrDefaultBool("GET_MEDIA_TOKEN", true)
|
|
constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", false)
|
|
constant.UpdateTask = GetEnvOrDefaultBool("UPDATE_TASK", true)
|
|
constant.TaskPluginEnabled = GetEnvOrDefaultBool("TASK_PLUGIN_ENABLED", true)
|
|
constant.AzureDefaultAPIVersion = GetEnvOrDefaultString("AZURE_DEFAULT_API_VERSION", "2025-04-01-preview")
|
|
constant.NotifyLimitCount = GetEnvOrDefault("NOTIFY_LIMIT_COUNT", 2)
|
|
constant.NotificationLimitDurationMinute = GetEnvOrDefault("NOTIFICATION_LIMIT_DURATION_MINUTE", 10)
|
|
// GenerateDefaultToken 是否生成初始令牌,默认关闭。
|
|
constant.GenerateDefaultToken = GetEnvOrDefaultBool("GENERATE_DEFAULT_TOKEN", false)
|
|
// 是否启用错误日志
|
|
constant.ErrorLogEnabled = GetEnvOrDefaultBool("ERROR_LOG_ENABLED", false)
|
|
// 任务轮询时查询的最大数量
|
|
constant.TaskQueryLimit = GetEnvOrDefault("TASK_QUERY_LIMIT", 1000)
|
|
// 异步任务超时时间(分钟),超过此时间未完成的任务将被标记为失败并退款。0 表示禁用。
|
|
constant.TaskTimeoutMinutes = GetEnvOrDefault("TASK_TIMEOUT_MINUTES", 1440)
|
|
// Consecutive unrecognized/transient poll failures before the task is failed and refunded.
|
|
constant.TaskPollMaxFailures = GetEnvOrDefault("TASK_POLL_MAX_FAILURES", 20)
|
|
// 声明式任务协议桥只观察数据库;这些值控制一次客户端观察连接,
|
|
// 不改变后台轮询或结算生命周期。
|
|
constant.TaskPluginProtocolTimeoutSeconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_TIMEOUT_SECONDS", 600)
|
|
constant.TaskPluginProtocolTickMilliseconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_TICK_MILLISECONDS", 2000)
|
|
constant.TaskPluginProtocolTickJitterMilliseconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_TICK_JITTER_MILLISECONDS", 500)
|
|
constant.TaskPluginProtocolHeartbeatSeconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_HEARTBEAT_SECONDS", 15)
|
|
|
|
soraPatchStr := GetEnvOrDefaultString("TASK_PRICE_PATCH", "")
|
|
if soraPatchStr != "" {
|
|
var taskPricePatches []string
|
|
soraPatches := strings.SplitSeq(soraPatchStr, ",")
|
|
for patch := range soraPatches {
|
|
trimmedPatch := strings.TrimSpace(patch)
|
|
if trimmedPatch != "" {
|
|
taskPricePatches = append(taskPricePatches, trimmedPatch)
|
|
}
|
|
}
|
|
constant.TaskPricePatches = taskPricePatches
|
|
}
|
|
|
|
// Initialize trusted redirect domains for URL validation
|
|
trustedDomainsStr := GetEnvOrDefaultString("TRUSTED_REDIRECT_DOMAINS", "")
|
|
var trustedDomains []string
|
|
domains := strings.SplitSeq(trustedDomainsStr, ",")
|
|
for domain := range domains {
|
|
trimmedDomain := strings.TrimSpace(domain)
|
|
if trimmedDomain != "" {
|
|
// Normalize domain to lowercase
|
|
trustedDomains = append(trustedDomains, strings.ToLower(trimmedDomain))
|
|
}
|
|
}
|
|
constant.TrustedRedirectDomains = trustedDomains
|
|
}
|