diff --git a/README.md b/README.md index 1d2dfaf6..17f042ff 100644 --- a/README.md +++ b/README.md @@ -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/`,需要时可配 | | `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 降级,功能照常) | @@ -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`) 保存后会提示"需重启进程生效"。配置写入采用「深合并且原子替换」:只更新面板表单覆盖的键, 用户手写的未知键与其余字段原样保留。 diff --git a/cmd/server/config.go b/cmd/server/config.go index 1c9d5b2a..c7eb9db1 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -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; diff --git a/cmd/server/main.go b/cmd/server/main.go index f2056dbc..c2ef1c58 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 文件路径推出,避免再加一个配置项。 @@ -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) @@ -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) diff --git a/config.example.json b/config.example.json index 4461074d..3ceb489f 100644 --- a/config.example.json +++ b/config.example.json @@ -56,6 +56,10 @@ "features": { "sanitize_blacklist_fingerprints": true }, + "models": { + "_comment": "hidden: 不对外暴露的模型黑名单。裸名=两域都藏;\"cn:xxx\"/\"global:xxx\"=只藏该域。/v1/models 列表过滤 + chat 点名拒绝,面板保存后热生效。", + "hidden": [] + }, "prompt": { "mode": "passthrough", "file": "" diff --git a/internal/livecfg/livecfg.go b/internal/livecfg/livecfg.go index 0db0c341..9d97da3c 100644 --- a/internal/livecfg/livecfg.go +++ b/internal/livecfg/livecfg.go @@ -9,6 +9,7 @@ package livecfg import ( + "strings" "sync/atomic" "time" ) @@ -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 原子持有当前快照。 @@ -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 +} diff --git a/internal/livecfg/livecfg_test.go b/internal/livecfg/livecfg_test.go new file mode 100644 index 00000000..6c687eb6 --- /dev/null +++ b/internal/livecfg/livecfg_test.go @@ -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) + } + } +} diff --git a/internal/server/handler.go b/internal/server/handler.go index 1c96d5f4..10b9df1b 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -62,6 +62,10 @@ type Config struct { // (modelList 不列 global 名单)。 GlobalEnabled bool + // ModelsHidden 模型黑名单原始条目(config models.hidden;Live 为 nil 的 + // 裸用/测试路径经 loadLive() 归一化后使用)。 + ModelsHidden []string + // Usage 逐请求用量记录器(可选;nil = 不记录)。 // 在 recordAttempt 这一唯一汇聚点调用,因此流式/非流式、成功/失败都会计入, // 且与 pool 的每账号累计器同源,两条口径不会漂移。 @@ -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), } } @@ -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", @@ -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", @@ -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 分支填充)。 diff --git a/internal/server/handler_test.go b/internal/server/handler_test.go index 0ccab5bd..0592e832 100644 --- a/internal/server/handler_test.go +++ b/internal/server/handler_test.go @@ -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) + } +}