Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
02bc4a1
fix(gemini): normalize markdown data images to inlineData
zaneWWWWWW Jul 22, 2026
77f2f74
test(gemini): preserve official inlineData responses
zaneWWWWWW Jul 22, 2026
f10df1d
feat(branding): complete Nanoapple site identity
zaneWWWWWW Jul 22, 2026
dc2448a
fix(groups): load special usable groups from options
zaneWWWWWW Jul 24, 2026
246541b
fix(routing): honor explicit channel abilities in cache
zaneWWWWWW Jul 24, 2026
0dcbcdd
feat: log sanitized failed image requests
zaneWWWWWW Jul 24, 2026
9e9dcba
fix: handle malformed image audit requests
zaneWWWWWW Jul 24, 2026
795029b
feat: 完善转发追踪与模型映射
zaneWWWWWW Jul 25, 2026
462861b
fix: 联动首页与导航文档入口
zaneWWWWWW Jul 25, 2026
919c475
修复:仅失败追踪误记录成功请求
zaneWWWWWW Jul 26, 2026
181da5c
fix(web): recover from stale deployment chunks
zaneWWWWWW Aug 2, 2026
0d8ef61
流程:建立中文 PR 与长期分支治理规范
nanoapple8888 Aug 2, 2026
3f83fbe
功能(web):精简模型价格编辑字段
zaneWWWWWW Aug 4, 2026
6ecd0a8
Merge pull request #2 from nanoapple8888/feat/simplify-model-pricing-…
nanoapple8888 Aug 4, 2026
e8cde5e
修复(web):恢复图像与音频价格编辑项
zaneWWWWWW Aug 4, 2026
4caecb7
Merge pull request #3 from nanoapple8888/fix/restore-model-pricing-lanes
nanoapple8888 Aug 4, 2026
ba1a13f
修复(channel):编辑渠道时使用已保存凭据获取模型
zaneWWWWWW Aug 5, 2026
1d990d9
Merge pull request #6 from nanoapple8888/fix/fetch-models-saved-chann…
nanoapple8888 Aug 5, 2026
1691c19
修复(channel):新建渠道获取模型时应用代理与请求头覆盖
zaneWWWWWW Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@
# ENABLE_PPROF=true
# 启用调试模式
# DEBUG=true
# IMAGE_FAILURE_REQUEST_LOG_ENABLED=false
# Records one sanitized relay_trace JSON event for every proxied request.
# RELAY_TRACE_LOG_ENABLED=false
# Only emit relay_trace events for 4xx/5xx responses or relay errors.
# RELAY_TRACE_LOG_FAILURES_ONLY=false
# Maximum retained redacted body preview per direction. Binary media is never retained.
# RELAY_TRACE_LOG_MAX_BODY_KB=256
# Store complete unredacted request and response bodies. Enable only for short troubleshooting windows.
# RELAY_TRACE_LOG_FULL_BODY_ENABLED=false
# Hard capture limit per request or response direction when complete body logging is enabled.
# RELAY_TRACE_LOG_FULL_BODY_MAX_MB=64
# Pyroscope 配置
# PYROSCOPE_URL=http://localhost:4040
# PYROSCOPE_APP_NAME=new-api
Expand Down
49 changes: 29 additions & 20 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
# ⚠️ 提交说明 / PR Notice
# 提交说明
> [!IMPORTANT]
>
> - 请提供**人工撰写**的简洁摘要,避免直接粘贴未经整理的 AI 输出。
> - PR 标题、变更说明、验证结果和风险说明统一使用中文。
> - 可以使用 AI 辅助,但必须如实披露,并由提交人复核代码和验证结果。

## 📝 变更描述 / Description
(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)
## 变更描述
<!-- 说明做了什么、为什么这样修改,以及核心实现逻辑。 -->

## 🚀 变更类型 / Type of change
- [ ] 🐛 Bug 修复 (Bug fix) - *请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug*
- [ ] 新功能 (New feature) - *重大特性建议先通过 Issue 沟通*
- [ ] ⚡ 性能优化 / 重构 (Refactor)
- [ ] 📝 文档更新 (Documentation)
## 变更类型
- [ ] Bug 修复请关联对应 Issue,避免将设计取舍或预期差异直接归类为缺陷
- [ ] 新功能重大特性建议先通过 Issue 沟通
- [ ] 性能优化或重构
- [ ] 文档或流程更新

## 🔗 关联任务 / Related Issue
- Closes # (如有)
## 关联任务
- 关联 Issue:#(如无请填写“无”)

## AI 辅助披露
- [ ] 未使用 AI 辅助
- [ ] 使用了 AI 辅助,工具及范围:
- [ ] 我已人工复核 AI 辅助产生的代码、文档和验证结果

## ✅ 提交前检查项 / Checklist
- [ ] **人工确认:** 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。
- [ ] **Bug fix 说明:** 若此 PR 标记为 `Bug fix`,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
- [ ] **变更理解:** 我已理解这些更改的工作原理及可能影响。
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
- [ ] **中文说明:** PR 标题和正文已使用中文,必要的技术标识除外。
- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。
- [ ] **缺陷说明:** 若标记为 Bug 修复,已说明复现条件、根因和回归验证。
- [ ] **变更理解:** 我已理解这些更改的工作原理及可能影响。
- [ ] **范围聚焦:** 本 PR 未包含与当前任务无关的改动。
- [ ] **本地验证:** 已运行相关测试或手动验证,并在下方记录结果。
- [ ] **安全合规:** 代码和日志中没有凭据、令牌或其他敏感信息。

## 验证结果
<!-- 列出执行过的测试、构建、接口检查或人工验证,并注明结果。 -->

## 📸 运行证明 / Proof of Work
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
## 风险与回滚
<!-- 说明潜在影响、部署要求和回滚方式;纯文档变更可填写“无”。 -->
42 changes: 42 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Metaapple 仓库协作规范

本仓库使用一个共享基线和两个品牌生产分支:

| 分支 | 用途 |
| --- | --- |
| `main` | 共享代码基线 |
| `brand/nanoapple` | Nanoapple 生产代码 |
| `brand/macroapple` | Macroapple 生产代码 |

## 分支与提交

1. 禁止直接向上述三个长期分支推送日常修改。
2. 每项修改从目标分支创建独立工作分支,例如
`fix/stale-chunk-recovery` 或 `docs/deployment-record`。
3. 提交信息使用中文,保留必要的代码符号、路径和协议名。推荐格式:
`修复(web):处理部署后旧分包加载失败`。
4. 一个提交只处理一个明确主题,不混入无关格式化或重构。
5. 禁止强制推送、删除或重写长期分支历史。

## Pull Request

1. 所有长期分支变更必须通过 Pull Request 合并。
2. PR 标题、变更描述、验证结果、风险和回滚说明统一使用中文。
3. 必须完整填写 `.github/PULL_REQUEST_TEMPLATE.md`,不得删除模板章节。
4. 共享修复先提交到 `main`,验证后再通过独立 PR 同步到对应品牌分支。
5. 品牌文案、默认值和品牌资源直接以对应品牌分支为目标创建 PR。
6. 使用 AI 辅助时必须如实注明工具和范围;提交人仍需复核代码和测试结果。
7. 合并前确认 CI 通过、讨论已解决,并记录实际执行的测试。

## 部署

PR 合并不等于自动授权生产部署。生产部署必须单独确认目标品牌,并在部署后检查:

- 应用状态接口和容器健康状态;
- 公网 Cloudflare 入口;
- 实际运行镜像与合并提交;
- 另一个品牌的回归状态;
- 对应部署或测试记录。

Nanoapple 与 Macroapple 的数据库、Redis、密钥、日志和运行目录相互独立,禁止在
一次品牌部署中顺带修改或重启另一个品牌。
6 changes: 6 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,12 @@ docker run --name new-api -d --restart always \
| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` |
| `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | Error log switch | `false` |
| `IMAGE_FAILURE_REQUEST_LOG_ENABLED` | Log sanitized failed image requests; keeps prompts and generation parameters while removing credentials, Base64, and file URIs | `false` |
| `RELAY_TRACE_LOG_ENABLED` | Log a sanitized end-to-end trace for every relay request: downstream request/response, each upstream attempt, status, headers, and body previews | `false` |
| `RELAY_TRACE_LOG_FAILURES_ONLY` | When relay tracing is enabled, only log 4xx/5xx downstream responses or relay errors | `false` |
| `RELAY_TRACE_LOG_MAX_BODY_KB` | Maximum retained redacted body preview for each trace direction. Binary media is represented by size and a sample hash only | `256` |
| `RELAY_TRACE_LOG_FULL_BODY_ENABLED` | Store complete, unredacted request and response bodies in relay traces. Enable only during short troubleshooting windows because logs can contain private data and grow quickly | `false` |
| `RELAY_TRACE_LOG_FULL_BODY_MAX_MB` | Hard capture limit per request or response direction while complete body logging is enabled | `64` |
| `PYROSCOPE_URL` | Pyroscope server address | - |
| `PYROSCOPE_APP_NAME` | Pyroscope application name | `new-api` |
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope basic auth user | - |
Expand Down
6 changes: 6 additions & 0 deletions README.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,12 @@ docker run --name new-api -d --restart always \
| `MAX_REQUEST_BODY_MB` | 请求体最大大小(MB,**解压后**计;防止超大请求/zip bomb 导致内存暴涨),超过将返回 `413` | `32` |
| `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | 错误日志开关 | `false` |
| `IMAGE_FAILURE_REQUEST_LOG_ENABLED` | 记录脱敏后的生图失败请求;保留 Prompt/尺寸/生成参数,移除密钥、Base64 和文件 URI | `false` |
| `RELAY_TRACE_LOG_ENABLED` | 记录每个转发请求的脱敏全链路:下游请求/响应、每次上游尝试、状态、请求头与请求体预览 | `false` |
| `RELAY_TRACE_LOG_FAILURES_ONLY` | 启用全链路日志时,仅记录下游响应为 4xx/5xx 或转发错误的请求 | `false` |
| `RELAY_TRACE_LOG_MAX_BODY_KB` | 每个方向保留的最大脱敏请求体预览;二进制媒体仅记录大小和样本哈希 | `256` |
| `RELAY_TRACE_LOG_FULL_BODY_ENABLED` | 在转发追踪中记录完整、未脱敏的请求与响应正文。仅应在短时间排障时启用,因为日志可能含隐私数据且增长很快 | `false` |
| `RELAY_TRACE_LOG_FULL_BODY_MAX_MB` | 启用完整正文记录时,每个请求或响应方向的硬捕获上限 | `64` |
| `PYROSCOPE_URL` | Pyroscope 服务地址 | - |
| `PYROSCOPE_APP_NAME` | Pyroscope 应用名 | `new-api` |
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth 用户名 | - |
Expand Down
20 changes: 20 additions & 0 deletions common/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,26 @@ func initConstantEnv() {
constant.GenerateDefaultToken = GetEnvOrDefaultBool("GENERATE_DEFAULT_TOKEN", false)
// 是否启用错误日志
constant.ErrorLogEnabled = GetEnvOrDefaultBool("ERROR_LOG_ENABLED", false)
// 是否记录脱敏后的生图失败请求,供渠道与参数诊断使用。
constant.ImageFailureRequestLogEnabled = GetEnvOrDefaultBool("IMAGE_FAILURE_REQUEST_LOG_ENABLED", false)
// 记录下游、上游和最终响应的完整转发链路,仅用于服务端排障。
constant.RelayTraceLogEnabled = GetEnvOrDefaultBool("RELAY_TRACE_LOG_ENABLED", false)
constant.RelayTraceLogFailureOnly = GetEnvOrDefaultBool("RELAY_TRACE_LOG_FAILURES_ONLY", false)
constant.RelayTraceLogMaxBodyKB = GetEnvOrDefault("RELAY_TRACE_LOG_MAX_BODY_KB", 256)
if constant.RelayTraceLogMaxBodyKB < 1 {
constant.RelayTraceLogMaxBodyKB = 1
}
if constant.RelayTraceLogMaxBodyKB > 4096 {
constant.RelayTraceLogMaxBodyKB = 4096
}
constant.RelayTraceLogFullBodyEnabled = GetEnvOrDefaultBool("RELAY_TRACE_LOG_FULL_BODY_ENABLED", false)
constant.RelayTraceLogFullBodyMaxMB = GetEnvOrDefault("RELAY_TRACE_LOG_FULL_BODY_MAX_MB", 64)
if constant.RelayTraceLogFullBodyMaxMB < 1 {
constant.RelayTraceLogFullBodyMaxMB = 1
}
if constant.RelayTraceLogFullBodyMaxMB > 256 {
constant.RelayTraceLogFullBodyMaxMB = 256
}
// 任务轮询时查询的最大数量
constant.TaskQueryLimit = GetEnvOrDefault("TASK_QUERY_LIMIT", 1000)
// 异步任务超时时间(分钟),超过此时间未完成的任务将被标记为失败并退款。0 表示禁用。
Expand Down
6 changes: 6 additions & 0 deletions constant/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ var NotifyLimitCount int
var NotificationLimitDurationMinute int
var GenerateDefaultToken bool
var ErrorLogEnabled bool
var ImageFailureRequestLogEnabled bool
var RelayTraceLogEnabled bool
var RelayTraceLogFailureOnly bool
var RelayTraceLogMaxBodyKB int
var RelayTraceLogFullBodyEnabled bool
var RelayTraceLogFullBodyMaxMB int
var TaskQueryLimit int
var TaskTimeoutMinutes int

Expand Down
106 changes: 70 additions & 36 deletions controller/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -1211,13 +1211,9 @@ func buildAdvancedCustomModelPreviewChannel(req fetchModelsRequest) (*model.Chan
}
channel = savedChannel
} else {
key := strings.TrimSpace(req.Key)
if key != "" {
key = strings.Split(key, "\n")[0]
}
channel = &model.Channel{
Type: req.Type,
Key: key,
Key: firstFetchModelsKey(req.Key, false),
}
}

Expand Down Expand Up @@ -1245,12 +1241,28 @@ func buildAdvancedCustomModelPreviewChannel(req fetchModelsRequest) (*model.Chan
}
channel.SetOtherSettings(settings)

if err := applyFetchModelsRequestOverrides(channel, req); err != nil {
return nil, err
}

if err := validateChannel(channel, false); err != nil {
return nil, err
}
return channel, nil
}

// applyFetchModelsRequestOverrides applies form-preview header/proxy overrides.
// Saved ordinary channels intentionally ignore request overrides and keep DB values.
func applyFetchModelsRequestOverrides(channel *model.Channel, req fetchModelsRequest) error {
if channel == nil {
return fmt.Errorf("channel is required")
}
if req.HeaderOverride != nil {
rawHeaderOverride := strings.TrimSpace(*req.HeaderOverride)
if rawHeaderOverride != "" {
var headerOverride map[string]any
if err := common.UnmarshalJsonStr(rawHeaderOverride, &headerOverride); err != nil {
return nil, fmt.Errorf("header_override must be a JSON object: %w", err)
return fmt.Errorf("header_override must be a JSON object: %w", err)
}
}
channel.HeaderOverride = &rawHeaderOverride
Expand All @@ -1260,8 +1272,52 @@ func buildAdvancedCustomModelPreviewChannel(req fetchModelsRequest) (*model.Chan
channelSettings.Proxy = strings.TrimSpace(*req.Proxy)
channel.SetSetting(channelSettings)
}
return nil
}

if err := validateChannel(channel, false); err != nil {
func firstFetchModelsKey(raw string, keepMultiline bool) string {
key := strings.TrimSpace(raw)
if keepMultiline || key == "" {
return key
}
for _, line := range strings.Split(key, "\n") {
if trimmed := strings.TrimSpace(line); trimmed != "" {
return trimmed
}
}
return ""
}

func buildFetchModelsChannel(req fetchModelsRequest) (*model.Channel, error) {
if req.ChannelID > 0 && req.Type != constant.ChannelTypeAdvancedCustom {
channel, err := model.GetChannelById(req.ChannelID, true)
if err != nil {
return nil, err
}
if channel.Type != req.Type {
return nil, fmt.Errorf("channel %d type does not match request", req.ChannelID)
}
return channel, nil
}

if req.Type == constant.ChannelTypeAdvancedCustom {
return buildAdvancedCustomModelPreviewChannel(req)
}

baseURL := ""
if req.BaseURL != nil {
baseURL = strings.TrimSpace(*req.BaseURL)
}
if baseURL == "" {
baseURL = constant.ChannelBaseURLs[req.Type]
}

channel := &model.Channel{
Type: req.Type,
Key: firstFetchModelsKey(req.Key, req.Type == constant.ChannelTypeCodex),
BaseURL: &baseURL,
}
if err := applyFetchModelsRequestOverrides(channel, req); err != nil {
return nil, err
}
return channel, nil
Expand All @@ -1278,35 +1334,13 @@ func FetchModels(c *gin.Context) {
return
}

var channel *model.Channel
if req.Type == constant.ChannelTypeAdvancedCustom || req.ChannelID > 0 {
var err error
channel, err = buildAdvancedCustomModelPreviewChannel(req)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
} else {
baseURL := ""
if req.BaseURL != nil {
baseURL = strings.TrimSpace(*req.BaseURL)
}
if baseURL == "" {
baseURL = constant.ChannelBaseURLs[req.Type]
}

key := strings.TrimSpace(req.Key)
if req.Type != constant.ChannelTypeCodex {
key = strings.Split(key, "\n")[0]
}
channel = &model.Channel{
Type: req.Type,
Key: key,
BaseURL: &baseURL,
}
channel, err := buildFetchModelsChannel(req)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}

models, err := fetchChannelUpstreamModelIDs(channel)
Expand Down
23 changes: 22 additions & 1 deletion controller/channel_upstream_update.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package controller

import (
"bytes"
"context"
"errors"
"fmt"
Expand Down Expand Up @@ -267,6 +268,9 @@ func parseOpenAIModelIDs(body []byte) ([]string, error) {
Data *[]OpenAIModel `json:"data"`
}
if err := common.Unmarshal(body, &result); err != nil {
if formatted := formatFetchModelsBodyError(body, err); formatted != err {
return nil, formatted
}
return nil, fmt.Errorf("invalid OpenAI Models response: %w", err)
}
if result.Data == nil {
Expand Down Expand Up @@ -304,6 +308,23 @@ func sanitizeFetchModelsError(err error, key string) error {
return errors.New(message)
}

// formatFetchModelsBodyError turns opaque JSON parse failures into actionable
// diagnostics when the upstream returned HTML or an empty body.
func formatFetchModelsBodyError(body []byte, err error) error {
if err == nil {
return nil
}
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return fmt.Errorf("invalid models response: empty body: %w", err)
}
lower := bytes.ToLower(trimmed)
if trimmed[0] == '<' || bytes.HasPrefix(lower, []byte("<!doctype")) || bytes.HasPrefix(lower, []byte("<html")) {
return errors.New("upstream returned HTML instead of JSON model list; check Base URL, API key, proxy, and header override")
}
return err
}

func getFetchModelsResponseBody(method string, requestURL string, channel *model.Channel, headers http.Header) ([]byte, error) {
request, err := http.NewRequest(method, requestURL, nil)
if err != nil {
Expand Down Expand Up @@ -414,7 +435,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {

var result OpenAIModelsResponse
if err := common.Unmarshal(body, &result); err != nil {
return nil, err
return nil, sanitizeFetchModelsError(formatFetchModelsBodyError(body, err), key)
}
ids := lo.Map(result.Data, func(item OpenAIModel, _ int) string {
if channel.Type == constant.ChannelTypeGemini {
Expand Down
Loading
Loading