Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ curl -s http://localhost:7863/v1/chat/completions \
| `upstream.idle_timeout_seconds` | `300` | 聊天流中空闲上限(活跃续命,静默断流) |
| `upstream.user_agent` | 空 | 出站 User-Agent 覆盖(空 = 现状 `CLI/2.63.2 CodeBuddy/2.63.2`)。官网「使用端」列按出站 UA 服务端归因;官方 WorkBuddy 桌面 UA 为 `WorkBuddy/<version>`,需要时可配 |
| `features.sanitize_blacklist_fingerprints` | `true` | 出站请求体黑名单指纹脱敏 |
| `models.hidden` | `[]` | 模型黑名单:不对外暴露的模型。裸名=两域都藏;`"cn:xxx"`/`"global:xxx"`=只藏该域。`GET /v1/models` 列表过滤 + `POST /v1/chat/completions` 点名拒绝(400 `model_unavailable`)。面板保存后热生效 |
| `prompt.mode` | `custom` | 系统提示词模式:`custom` = 网关用自有提示词替换客户端 system;`append` = 开头连续 system/developer 块后插网关提示词(既有消息逐字不动);`passthrough` = 透传客户端原始 system(降级重试仍切中性提示词) |
| `prompt.file` | 空 | 提示词文件路径;空 = 内置默认(约 2KB);路径非空但不可读 → 启动报错 |
| `upstash.url` / `upstash.token` | 空 | 空 = 纯内存模式(Noop 降级,功能照常) |
Expand Down Expand Up @@ -658,7 +659,7 @@ http://127.0.0.1:7863/panel/
来源信息比 token 计数敏感(IP 属个人信息),因此由 `logging.request_client_info` 控制,**缺省开启、热生效、面板「配置 → 日志」可关**;关闭后归档与面板都不再出现来源字段(老归档里没有该字段的条目显示 `—`)。

**配置热生效**:保存配置后,`api_key`、`cooldown.soft_rate`、`features.sanitize_blacklist_fingerprints`、
`logging.request_client_info`、`pool.*`(熔断/在途/权重)、`schedule.*`(时点/开关/余额刷新间隔)**立即生效,无需重启**;
`models.hidden`、`logging.request_client_info`、`pool.*`(熔断/在途/权重)、`schedule.*`(时点/开关/余额刷新间隔)**立即生效,无需重启**;
涉及进程装配期依赖的字段(`listen`、`auth_dir`、`state_file`、`upstream.*`、`upstash.*`、`session_sticky.ttl`)
保存后会提示"需重启进程生效"。配置写入采用「深合并且原子替换」:只更新面板表单覆盖的键,
用户手写的未知键与其余字段原样保留。
Expand Down
7 changes: 7 additions & 0 deletions cmd/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ type Config struct {
SanitizeBlacklistFingerprints bool `json:"sanitize_blacklist_fingerprints"`
} `json:"features"`

Models struct {
// Hidden 不对外暴露的模型黑名单。裸名=两域都藏;"cn:xxx"/"global:xxx"=只藏该域。
// 生效点:GET /v1/models 列表过滤 + POST /v1/chat/completions 点名拒绝。
// 面板保存后经 livecfg 热生效,无需重启。
Hidden []string `json:"hidden"`
} `json:"models"`

Prompt struct {
// Mode passthrough(默认)= 透传客户端原始 system(降级重试仍会切到 Degraded);
// custom = 网关用自有系统提示词替换客户端 system/developer;
Expand Down
5 changes: 5 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ func main() {
SoftCooldown: cfg.SoftRateDur,
SanitizeFingerprints: cfg.Features.SanitizeBlacklistFingerprints,
RecordClientInfo: cfg.Logging.RequestClientInfo,
HiddenModels: livecfg.NormalizeHiddenModels(cfg.Models.Hidden),
})
// 用量记录器:与 state 文件同目录,随 state_file 配置一起搬移。
// datapath 由 state 文件路径推出,避免再加一个配置项。
Expand Down Expand Up @@ -310,6 +311,9 @@ func main() {
RecordClientInfo: cfg.Logging.RequestClientInfo,
// handler 侧第三道闸(global realm):false(显式逃生门)时不列 global: 模型名。
GlobalEnabled: cfg.Global.Enabled,
// 模型黑名单原始条目;运行期以 livecfg 快照为准(热生效),此处供
// Live 为 nil 的裸用/测试路径。
ModelsHidden: cfg.Models.Hidden,
})

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
Expand Down Expand Up @@ -484,6 +488,7 @@ func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up
SoftCooldown: newCfg.SoftRateDur,
SanitizeFingerprints: newCfg.Features.SanitizeBlacklistFingerprints,
RecordClientInfo: newCfg.Logging.RequestClientInfo,
HiddenModels: livecfg.NormalizeHiddenModels(newCfg.Models.Hidden),
})
up.SanitizeFingerprints.Store(newCfg.Features.SanitizeBlacklistFingerprints)
p.SetBreaker(newCfg.Pool.BreakerThreshold, newCfg.BreakerCooldownDur, newCfg.BreakerCooldownMaxD)
Expand Down
4 changes: 4 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@
"features": {
"sanitize_blacklist_fingerprints": true
},
"models": {
"_comment": "hidden: 不对外暴露的模型黑名单。裸名=两域都藏;\"cn:xxx\"/\"global:xxx\"=只藏该域。/v1/models 列表过滤 + chat 点名拒绝,面板保存后热生效。",
"hidden": []
},
"prompt": {
"mode": "passthrough",
"file": ""
Expand Down
30 changes: 30 additions & 0 deletions internal/livecfg/livecfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
package livecfg

import (
"strings"
"sync/atomic"
"time"
)
Expand All @@ -19,6 +20,7 @@ type Snapshot struct {
SoftCooldown time.Duration // 429 软冷却基数(<=0 时调用方回退内置默认)
SanitizeFingerprints bool // 出站请求体指纹脱敏
RecordClientInfo bool // 请求日志是否记录调用来源(客户端 IP / UA)
HiddenModels map[string]struct{} // 精确隐藏的模型 id(已归一化为 "cn:x"/"global:x" 全量形式);nil = 不隐藏
}

// Holder 原子持有当前快照。
Expand Down Expand Up @@ -46,3 +48,31 @@ func (h *Holder) Load() Snapshot {

// Store 整体替换快照。
func (h *Holder) Store(s Snapshot) { h.p.Store(&s) }

// NormalizeHiddenModels 把用户配置的 hidden 条目归一化为精确 id 集合。
//
// 条目带合法 "cn:"/"global:" 前缀 → 只隐藏该域;裸名 → 两域都隐藏。
// 大小写敏感(与 internal/server.resolveModel 的前缀规则对称:取第一个 ":",
// 前段恰为 "cn"/"global" 才剥离)。返回的 map 只读共享,调用方不得修改。
func NormalizeHiddenModels(hidden []string) map[string]struct{} {
if len(hidden) == 0 {
return nil
}
out := make(map[string]struct{}, len(hidden)*2)
for _, m := range hidden {
m = strings.TrimSpace(m)
if m == "" {
continue
}
if prefix, bare, ok := strings.Cut(m, ":"); ok && (prefix == "cn" || prefix == "global") {
out[prefix+":"+bare] = struct{}{}
} else {
out["cn:"+m] = struct{}{}
out["global:"+m] = struct{}{}
}
}
if len(out) == 0 {
return nil
}
return out
}
53 changes: 53 additions & 0 deletions internal/livecfg/livecfg_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package livecfg

import "testing"

// TestNormalizeHiddenModels 归一化语义:
// - nil/空/全空白 → nil(不隐藏)
// - 裸名 → 两域都藏("cn:x" + "global:x")
// - "cn:"/"global:" 前缀 → 只藏该域
// - 非 cn/global 前缀(如 "xx:yy")视为裸名整体(与 resolveModel 对称:
// 前缀不合法时 realm=cn、bare=原串,网关侧检查 "cn:xx:yy" 命中)
func TestNormalizeHiddenModels(t *testing.T) {
if got := NormalizeHiddenModels(nil); got != nil {
t.Errorf("nil → %v, want nil", got)
}
if got := NormalizeHiddenModels([]string{}); got != nil {
t.Errorf("empty → %v, want nil", got)
}
if got := NormalizeHiddenModels([]string{" ", ""}); got != nil {
t.Errorf("all blank → %v, want nil", got)
}

got := NormalizeHiddenModels([]string{"deepseek-v3"})
for _, want := range []string{"cn:deepseek-v3", "global:deepseek-v3"} {
if _, ok := got[want]; !ok {
t.Errorf("bare name: missing %q in %v", want, got)
}
}
if len(got) != 2 {
t.Errorf("bare name: got %d entries %v, want 2", len(got), got)
}

got = NormalizeHiddenModels([]string{"cn:pro-model"})
if _, ok := got["cn:pro-model"]; !ok {
t.Errorf("cn: prefix: missing %q in %v", "cn:pro-model", got)
}
if _, ok := got["global:pro-model"]; ok {
t.Errorf("cn: prefix: %q must not hide global realm", "global:pro-model")
}

// 前后空白被裁掉
got = NormalizeHiddenModels([]string{" glm-5.2 "})
if _, ok := got["cn:glm-5.2"]; !ok {
t.Errorf("trimmed bare name: missing %q in %v", "cn:glm-5.2", got)
}

// 非法前缀视为裸名整体
got = NormalizeHiddenModels([]string{"xx:yy"})
for _, want := range []string{"cn:xx:yy", "global:xx:yy"} {
if _, ok := got[want]; !ok {
t.Errorf("odd prefix: missing %q in %v", want, got)
}
}
}
23 changes: 23 additions & 0 deletions internal/server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ type Config struct {
// (modelList 不列 global 名单)。
GlobalEnabled bool

// ModelsHidden 模型黑名单原始条目(config models.hidden;Live 为 nil 的
// 裸用/测试路径经 loadLive() 归一化后使用)。
ModelsHidden []string

// Usage 逐请求用量记录器(可选;nil = 不记录)。
// 在 recordAttempt 这一唯一汇聚点调用,因此流式/非流式、成功/失败都会计入,
// 且与 pool 的每账号累计器同源,两条口径不会漂移。
Expand All @@ -85,6 +89,7 @@ func (h *Handler) loadLive() livecfg.Snapshot {
APIKey: h.cfg.APIKey,
SoftCooldown: h.cfg.SoftCooldown,
RecordClientInfo: h.cfg.RecordClientInfo,
HiddenModels: livecfg.NormalizeHiddenModels(h.cfg.ModelsHidden),
}
}

Expand Down Expand Up @@ -359,7 +364,12 @@ func applyModelInfoFields(entry map[string]any, mi upstream.ModelInfo) map[strin
// 纯动态:动态拉取失败/无号 → 该域空列表,无静态兜底。
func (h *Handler) modelList() []map[string]any {
out := make([]map[string]any, 0)
// models.hidden 黑名单:快照外提,避免逐条目重复 Load。
hidden := h.loadLive().HiddenModels
for _, mi := range h.fetchDynamicModels() {
if _, ok := hidden["cn:"+mi.ID]; ok {
continue
}
entry := map[string]any{
"id": "cn:" + mi.ID,
"object": "model",
Expand Down Expand Up @@ -403,6 +413,9 @@ func (h *Handler) modelList() []map[string]any {
}
globalEfforts, globalDefaults := h.cfg.Upstream.GlobalEffortSnapshot()
for _, id := range globalIDs {
if _, ok := hidden["global:"+id]; ok {
continue
}
entry := map[string]any{
"id": "global:" + id,
"object": "model",
Expand Down Expand Up @@ -551,6 +564,16 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) {
}
defer st.done()

// models.hidden 黑名单:点名调用直接拒绝,不打上游、不占选号。
// 与 /v1/models 的列表过滤对称(只藏列表不够,知道名字仍可硬调)。
if _, hide := h.loadLive().HiddenModels[realm+":"+bareModel]; hide {
writeOpenAIErrorHint(w, http.StatusBadRequest, "model_unavailable",
"model is hidden by gateway configuration, try another model", "")
st.status = http.StatusBadRequest
st.outcome = reqlog.OutcomeHTTPError
return
}

tried := map[string]bool{}
var lastErr error
// modelBlock 记录本次是否因「模型级冷却」而选不到号(下方 acct==nil 分支填充)。
Expand Down
92 changes: 92 additions & 0 deletions internal/server/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1832,3 +1832,95 @@ func TestChatMarkupRepairStrictModeRejectsUndeclaredTool(t *testing.T) {
t.Fatalf("未声明的工具名不得转换: %#v", msg["tool_calls"])
}
}

// TestChatHiddenModelRejected models.hidden 黑名单:点名调用直接 400 拒绝,
// 不打上游、不占选号;未隐藏的模型照常放行。
func TestChatHiddenModelRejected(t *testing.T) {
var calls int
up := newFakeUpstream(t, func(authz string) (int, string, bool) {
calls++
return 200, sseOK, true
})
p := testPoolWith(&auth.Auth{UID: "u1", AccessToken: "at1", ExpiresAt: 9999999999})
h := NewHandler(Config{Pool: p, Upstream: up, ModelsHidden: []string{"glm-5.2", "global:secret-model"}})

post := func(model string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
body := []byte(`{"model":"` + model + `","messages":[]}`)
h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(body)))
return rec
}

// 裸名隐藏 → 裸名 / cn: / global: 三种写法都拒绝
for _, model := range []string{"glm-5.2", "cn:glm-5.2", "global:glm-5.2"} {
rec := post(model)
if rec.Code != http.StatusBadRequest {
t.Errorf("model %q: code=%d, want 400", model, rec.Code)
}
if !strings.Contains(rec.Body.String(), "model_unavailable") {
t.Errorf("model %q: body=%s, want model_unavailable", model, rec.Body.String())
}
}
// 前缀精确隐藏:只藏 global 域
if rec := post("global:secret-model"); rec.Code != http.StatusBadRequest {
t.Errorf("global:secret-model: code=%d, want 400", rec.Code)
}
if calls != 0 {
t.Errorf("hidden models must not reach upstream: calls=%d", calls)
}

// cn:secret-model 不在黑名单 → 放行并打到上游
rec := post("cn:secret-model")
if rec.Code != http.StatusOK {
t.Fatalf("cn:secret-model: code=%d body=%s, want 200", rec.Code, rec.Body.String())
}
if calls != 1 {
t.Errorf("allowed model: upstream calls=%d, want 1", calls)
}
}

// TestModelsHiddenFiltered models.hidden 黑名单:/v1/models 列表里过滤掉隐藏模型。
// 用包级 dynamicModelsCache 预置目录,避免联网拉取。
func TestModelsHiddenFiltered(t *testing.T) {
dynamicModelsCache.Lock()
dynamicModelsCache.ids = []upstream.ModelInfo{{ID: "glm-5.2"}, {ID: "deepseek-v3"}}
dynamicModelsCache.fetched = time.Now()
dynamicModelsCache.Unlock()
defer func() {
dynamicModelsCache.Lock()
dynamicModelsCache.ids = nil
dynamicModelsCache.fetched = time.Time{}
dynamicModelsCache.lastFail = time.Time{}
dynamicModelsCache.Unlock()
}()

up := newFakeUpstream(t, func(authz string) (int, string, bool) {
return 200, `{"data":[]}`, false
})
p := testPoolWith(&auth.Auth{UID: "u1", AccessToken: "at1", ExpiresAt: 9999999999})
h := NewHandler(Config{Pool: p, Upstream: up, ModelsHidden: []string{"deepseek-v3"}})

rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code=%d body=%s, want 200", rec.Code, rec.Body.String())
}
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode /v1/models: %v", err)
}
ids := map[string]bool{}
for _, m := range resp.Data {
ids[m.ID] = true
}
if !ids["cn:glm-5.2"] {
t.Errorf("visible model missing from list: ids=%v", ids)
}
if ids["cn:deepseek-v3"] {
t.Errorf("hidden model leaked into /v1/models: ids=%v", ids)
}
}