From 7fcc20a2ebb7b1e45c7da5955cf75e0de21bd81f Mon Sep 17 00:00:00 2001 From: zhiqiangme <90136554+zhiqiangme@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:52:14 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(config):=20=E6=96=B0=E5=A2=9E=20cn.cha?= =?UTF-8?q?t=5Fbase=20=E8=A6=86=E7=9B=96=E5=9B=BD=E5=86=85=E7=89=88?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit copilot.tencent.com 对海外出口(如 Clash 全局 + TUN)的 TLS 握手直接复位, CN 模型目录与对话全部 EOF,/v1/models 只剩 global 模型。官方 WorkBuddy 桌面端 已改用 https://www.workbuddy.cn,实测该域海外出口可达,模型目录与 /v2/chat/completions 均正常。 仿照 global.chat_base:空 = 回落内置默认,老配置零影响;只在装配期生效, 已列入面板保存后的需重启字段。 --- cmd/server/cn_chat_base_test.go | 46 +++++++++++++++++++++++++++++++++ cmd/server/config.go | 9 +++++++ cmd/server/main.go | 11 ++++++++ config.example.json | 3 +++ 4 files changed, 69 insertions(+) create mode 100644 cmd/server/cn_chat_base_test.go diff --git a/cmd/server/cn_chat_base_test.go b/cmd/server/cn_chat_base_test.go new file mode 100644 index 00000000..27080755 --- /dev/null +++ b/cmd/server/cn_chat_base_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "slices" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// 老配置(无 cn 段)必须保留内置 CN base,零回归。 +func TestCNChatBaseDefaultKeepsBuiltin(t *testing.T) { + cfg, err := ParseConfig([]byte(`{}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + up := upstream.New() + want := up.ChatBaseCN + applyCNChatBase(up, cfg) + if up.ChatBaseCN != want { + t.Fatalf("ChatBaseCN=%q want builtin %q", up.ChatBaseCN, want) + } +} + +func TestCNChatBaseOverride(t *testing.T) { + cfg, err := ParseConfig([]byte(`{"cn":{"chat_base":"https://www.workbuddy.cn"}}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + up := upstream.New() + applyCNChatBase(up, cfg) + if up.ChatBaseCN != "https://www.workbuddy.cn" { + t.Fatalf("ChatBaseCN=%q want override", up.ChatBaseCN) + } + // global base 不受 cn 覆盖影响。 + if up.ChatBaseGlobal != "" { + t.Fatalf("ChatBaseGlobal=%q want untouched", up.ChatBaseGlobal) + } +} + +// cn.chat_base 只在装配期生效,面板保存后须提示重启。 +func TestCNChatBaseRequiresRestart(t *testing.T) { + cfg := Default() + if !slices.Contains(restartRequiredFields(cfg), "cn.chat_base") { + t.Fatal("cn.chat_base missing from restart-required fields") + } +} diff --git a/cmd/server/config.go b/cmd/server/config.go index 1c9d5b2a..49acd3cd 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -121,6 +121,15 @@ type Config struct { BillingBase string `json:"billing_base"` } `json:"global"` + CN struct { + // ChatBase 国内版 chat/模型目录上游 base 覆盖;空 = 回落内置默认 + // https://copilot.tencent.com(internal/upstream.New 的 ChatBaseCN)。 + // copilot.tencent.com 对海外出口(如 Clash 全局 + TUN)的 TLS 握手直接复位, + // CN 模型目录与对话全部 EOF;官方 WorkBuddy 桌面端已改用 https://www.workbuddy.cn, + // 该域海外出口可达,/v2/chat/completions、/v3/config 与模型目录路径同构。 + ChatBase string `json:"chat_base"` + } `json:"cn"` + Upstream struct { // TimeoutSeconds 短 RPC(refresh/checkin/balance/FetchModels)总时长上限,默认 120。 TimeoutSeconds int `json:"timeout_seconds"` diff --git a/cmd/server/main.go b/cmd/server/main.go index f2056dbc..ae8597c6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -164,6 +164,7 @@ func main() { up.GlobalEnabled = cfg.Global.Enabled up.ChatBaseGlobal = cfg.Global.ChatBase up.BillingBaseGlobal = cfg.Global.BillingBase + applyCNChatBase(up, cfg) auth.SetGlobalEnabled(cfg.Global.Enabled) // model.json 本地缓存接线(context_length/max_output_tokens 四级查找链第 3 级): // 数据目录与 state.json 同风格(Docker volume 持久化路径)。首次缺失/损坏自动 @@ -509,6 +510,14 @@ func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up return restartRequiredFields(newCfg), nil } +// applyCNChatBase 把 config cn.chat_base 装配进上游 client;空则保留内置默认, +// 老配置(无 cn 段)行为不变。 +func applyCNChatBase(up *upstream.Client, cfg *Config) { + if cfg.CN.ChatBase != "" { + up.ChatBaseCN = cfg.CN.ChatBase + } +} + // restartRequiredFields 返回本次改动中无法热生效、需要重启进程的字段名。 // 恒返回完整清单中的"与当前进程装配期依赖相关"的项——面板据此提示用户。 func restartRequiredFields(c *Config) []string { @@ -528,6 +537,8 @@ func restartRequiredFields(c *Config) []string { // 之后不再读取——不在 livecfg 热快照里,也无法热改。此前漏列,导致面板改完 // 显示"已保存"却不提示需要重启,用户以为没生效(issue #102 附带发现 2)。 out = append(out, "upstream.user_agent") + // cn.chat_base 同样只在装配期写进 up.ChatBaseCN(请求路径并发读取,不做热改)。 + out = append(out, "cn.chat_base") if c.Upstash.URL != "" || c.Upstash.Token != "" { out = append(out, "upstash") } diff --git a/config.example.json b/config.example.json index 4461074d..a8c502e5 100644 --- a/config.example.json +++ b/config.example.json @@ -41,6 +41,9 @@ "chat_base": "", "billing_base": "" }, + "cn": { + "chat_base": "" + }, "upstream": { "timeout_seconds": 120, "header_timeout_seconds": 120, From b54dabec6f025166946ca72f70c282a4996fae43 Mon Sep 17 00:00:00 2001 From: zhiqiangme <90136554+zhiqiangme@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:52:14 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(server):=20/v1/models=20=E6=8B=89?= =?UTF-8?q?=E5=8F=96=20CN=20=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=E8=AE=B0=E5=BD=95=20WARN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前失败只进负缓存、不留日志,CN 域不可达时网关只列 global 模型却毫无线索。 负缓存期内直接返回,不会刷屏。 --- internal/server/handler.go | 7 ++++ internal/server/models_fetch_log_test.go | 48 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 internal/server/models_fetch_log_test.go diff --git a/internal/server/handler.go b/internal/server/handler.go index 1c96d5f4..0fd79604 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -481,6 +481,13 @@ func (h *Handler) fetchDynamicModels() []upstream.ModelInfo { // 拉取失败只进负缓存(5min lastFail),不 NoteError:NoteError 喂的是 chat // 熔断器,models 端点偶发 5xx 跨界惩罚 chat 通道健康的账号; // models 拉取失败 ≠ 账号 chat 不可用。 + // 只在真正打上游失败时记一行(负缓存期内前面已直接 return,不会刷屏): + // 此前这里静默,CN 域不可达时 /v1/models 只剩 global 模型却毫无线索。 + if err == nil { + err = fmt.Errorf("models api returned empty list") + } + log.Printf("WARN: [server] /v1/models: CN 模型目录拉取失败(%s 内不再重试,cn: 模型暂不列出): %v", + modelsFetchFailCooldown, err) dynamicModelsCache.Lock() dynamicModelsCache.lastFail = time.Now() dynamicModelsCache.Unlock() diff --git a/internal/server/models_fetch_log_test.go b/internal/server/models_fetch_log_test.go new file mode 100644 index 00000000..a3037350 --- /dev/null +++ b/internal/server/models_fetch_log_test.go @@ -0,0 +1,48 @@ +package server + +import ( + "bytes" + "log" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// CN 模型目录拉取失败必须留下一行 WARN(此前静默,排障无线索),且负缓存期内 +// 的后续请求不重复记录。 +func TestModelsFetchFailureLogsOncePerCooldown(t *testing.T) { + dynamicModelsCache.Lock() + dynamicModelsCache.ids = nil + dynamicModelsCache.fetched = time.Time{} + dynamicModelsCache.lastFail = time.Time{} + dynamicModelsCache.Unlock() + + var buf bytes.Buffer + prev := log.Writer() + log.SetOutput(&buf) + t.Cleanup(func() { log.SetOutput(prev) }) + + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + return 500, `boom`, false + }) + p := testPoolWith(&auth.Auth{UID: "u1", AccessToken: "at1", ExpiresAt: 9999999999}) + h := NewHandler(Config{Pool: p, Upstream: up}) + + for i := 0; i < 3; i++ { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil)) + if rec.Code != 200 { + t.Fatalf("req %d: code=%d", i, rec.Code) + } + } + const marker = "/v1/models: CN 模型目录拉取失败" + if got := strings.Count(buf.String(), marker); got != 1 { + t.Fatalf("want 1 failure log line, got %d; log:\n%s", got, buf.String()) + } + if !strings.Contains(buf.String(), "status 500") { + t.Errorf("failure log should carry upstream error; log:\n%s", buf.String()) + } +}