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
46 changes: 46 additions & 0 deletions cmd/server/cn_chat_base_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
9 changes: 9 additions & 0 deletions cmd/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
11 changes: 11 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 持久化路径)。首次缺失/损坏自动
Expand Down Expand Up @@ -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 {
Expand All @@ -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")
}
Expand Down
3 changes: 3 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
"chat_base": "",
"billing_base": ""
},
"cn": {
"chat_base": ""
},
"upstream": {
"timeout_seconds": 120,
"header_timeout_seconds": 120,
Expand Down
7 changes: 7 additions & 0 deletions internal/server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
48 changes: 48 additions & 0 deletions internal/server/models_fetch_log_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}