From 44649af91eb6632024f1625387b71b3e651323a5 Mon Sep 17 00:00:00 2001 From: favo <827982457@qq.com> Date: Fri, 16 Oct 2026 20:32:22 +0800 Subject: [PATCH 1/5] feat(executor): support region-qualified model prefixes (workbuddy-cn/, workbuddy-gl/) - ResolveProviderFilter now returns (provider, region); the parsed region is applied as a region filter on the request context - ProviderPrefix recognizes dash-suffixed region qualifiers; NormalizeRegion maps the shorthand "gl" to global - changelog fragment (bilingual) --- .../region-qualified-model-prefix.md | 7 +++ internal/accounts/ids.go | 4 +- internal/api/compat_wrappers.go | 3 +- internal/executor/prepare.go | 62 ++++++++++++++++--- internal/executor/prepare_test.go | 48 ++++++++++++++ 5 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 changelog/unreleased/region-qualified-model-prefix.md diff --git a/changelog/unreleased/region-qualified-model-prefix.md b/changelog/unreleased/region-qualified-model-prefix.md new file mode 100644 index 00000000..8ef3ec9b --- /dev/null +++ b/changelog/unreleased/region-qualified-model-prefix.md @@ -0,0 +1,7 @@ +### English + +- Region-qualified model IDs: use `workbuddy-cn/glm-5.3-flash` or `workbuddy-gl/glm-5.3-flash` to route a request to CN or Global accounts respectively. Applies to all multi-region providers (WorkBuddy, Qoder). + +### 中文 + +- 区域限定模型 ID:使用 `workbuddy-cn/glm-5.3-flash` 或 `workbuddy-gl/glm-5.3-flash` 可将请求分别路由到 CN 或 Global 账户。适用于所有多区域 provider(WorkBuddy、Qoder)。 diff --git a/internal/accounts/ids.go b/internal/accounts/ids.go index c16b86f8..3a827282 100644 --- a/internal/accounts/ids.go +++ b/internal/accounts/ids.go @@ -34,10 +34,10 @@ func NormalizeProviderFamily(provider string) string { } // NormalizeRegion maps a stored or requested region onto the canonical -// routing region. An empty value is global. +// routing region. An empty value or the shorthand "gl" is global. func NormalizeRegion(region string) string { region = strings.ToLower(strings.TrimSpace(region)) - if region == "" { + if region == "" || region == "gl" { return "global" } return region diff --git a/internal/api/compat_wrappers.go b/internal/api/compat_wrappers.go index 7d6e2f37..b7486333 100644 --- a/internal/api/compat_wrappers.go +++ b/internal/api/compat_wrappers.go @@ -68,7 +68,8 @@ func (s *Server) resolveProviderFilter(req *translate.ChatRequest) string { if s != nil && s.App != nil { poolOn = s.CrossProviderModelPool.Load() } - return executor.ResolveProviderFilter(req, poolOn) + provider, _ := executor.ResolveProviderFilter(req, poolOn) + return provider } func (s *Server) applyPinnedProviderFilter(providerFilter, publicModel, prefer string) string { diff --git a/internal/executor/prepare.go b/internal/executor/prepare.go index 68a369b2..3efb3e6c 100644 --- a/internal/executor/prepare.go +++ b/internal/executor/prepare.go @@ -85,7 +85,7 @@ func (e ChatExecutor) Prepare(in PrepareInput) (PreparedRequest, error) { Message: "cross-provider model pool is disabled; use a provider-prefixed model ID such as qoder/glm-5.2", } } - providerFilter := ResolveProviderFilter(&request, in.CrossProviderPool) + providerFilter, modelRegion := ResolveProviderFilter(&request, in.CrossProviderPool) prefer := strings.TrimSpace(in.PreferAccount) providerFilter = ApplyPinnedProviderFilter(e.Pool, providerFilter, publicModel, prefer) if providerFilter != "" && !in.Identity.AllowsProvider(providerFilter) { @@ -141,6 +141,9 @@ func (e ChatExecutor) Prepare(in PrepareInput) (PreparedRequest, error) { if sessionKey != "" { ctx = WithSessionKey(ctx, sessionKey) } + if modelRegion != "" { + ctx = WithRegionFilter(ctx, modelRegion) + } if requestedReasoning != "" { log.Printf("request reasoning request_id=%q model=%q level=%q", requestID, request.Model, requestedReasoning) } @@ -162,12 +165,33 @@ func (e ChatExecutor) Prepare(in PrepareInput) (PreparedRequest, error) { // already-namespaced id ("command/deepseek/…"). var providerPrefixes = []string{"qoder/", "workbuddy/", "trae/", "devin/", "command/", "codex/", "zhipu/"} +// regionDashSuffixes are the region qualifiers that may be appended to a +// provider prefix with a dash: "workbuddy-cn/", "workbuddy-gl/". +var regionDashSuffixes = []string{"-cn/", "-gl/"} + +// regionAliases maps shorthand region tags to canonical region IDs. +var regionAliases = map[string]string{ + "cn": "cn", + "gl": "global", +} + +// regionTag extracts the bare region tag from a dash suffix like "-cn/". +func regionTag(suffix string) string { + return strings.TrimSuffix(strings.TrimPrefix(suffix, "-"), "/") +} + func ProviderPrefix(model string) string { model = strings.TrimSpace(model) for _, prefix := range providerPrefixes { if strings.HasPrefix(model, prefix) { return strings.TrimSuffix(prefix, "/") } + base := strings.TrimSuffix(prefix, "/") + for _, rs := range regionDashSuffixes { + if strings.HasPrefix(model, base+rs) { + return base + } + } } return "" } @@ -176,19 +200,41 @@ func RejectsBareModel(model string, crossProviderPool bool) bool { return strings.TrimSpace(model) != "" && !crossProviderPool && ProviderPrefix(model) == "" } -func ResolveProviderFilter(req *translate.ChatRequest, crossProviderPool bool) string { +// ResolveProviderFilter parses the provider prefix (and an optional region +// qualifier) from the model id, strips both from req.Model, and returns the +// provider family and the canonical region ("" when no region was specified). +// +// Supported forms: +// - "workbuddy/glm-5.3-flash" → provider=workbuddy, region="" +// - "workbuddy-cn/glm-5.3-flash" → provider=workbuddy, region=cn +// - "workbuddy-gl/glm-5.3-flash" → provider=workbuddy, region=global +// +// The region coexists with API-key grants: routeBaseMatches enforces both +// independently, so a prefix that conflicts with a grant yields no candidates. +func ResolveProviderFilter(req *translate.ChatRequest, crossProviderPool bool) (provider, region string) { model := strings.TrimSpace(req.Model) if model == "" { - return "" + return "", "" } - if prefix := ProviderPrefix(model); prefix != "" { - req.Model = strings.TrimPrefix(model, prefix+"/") - return prefix + for _, prefix := range providerPrefixes { + base := strings.TrimSuffix(prefix, "/") + // Region-qualified dash form: "workbuddy-cn/model" + for _, rs := range regionDashSuffixes { + if strings.HasPrefix(model, base+rs) { + req.Model = strings.TrimPrefix(model, base+rs) + return base, regionAliases[regionTag(rs)] + } + } + // Standard form: "workbuddy/model" + if strings.HasPrefix(model, prefix) { + req.Model = strings.TrimPrefix(model, prefix) + return base, "" + } } if crossProviderPool { - return "" + return "", "" } - return "qoder" + return "qoder", "" } func ApplyPinnedProviderFilter(pool *Pool, providerFilter, publicModel, prefer string) string { diff --git a/internal/executor/prepare_test.go b/internal/executor/prepare_test.go index bd5b7e80..d2d6ef97 100644 --- a/internal/executor/prepare_test.go +++ b/internal/executor/prepare_test.go @@ -263,6 +263,9 @@ func TestProviderPrefixRecognizesCommand(t *testing.T) { "zhipu/glm-5.2": "zhipu", "command/deepseek/deepseek-v4-pro": "command", "command/moonshotai/Kimi-K3": "command", + // Region-qualified dash form. + "workbuddy-cn/glm-5.3-flash": "workbuddy", + "workbuddy-gl/glm-5.3-flash": "workbuddy", // A bare org-namespaced Command Code id must NOT be read as a provider. "deepseek/deepseek-v4-pro": "", "moonshotai/Kimi-K3": "", @@ -274,6 +277,51 @@ func TestProviderPrefixRecognizesCommand(t *testing.T) { } } +func TestResolveProviderFilterRegionPrefix(t *testing.T) { + cases := []struct { + model string + wantProvider string + wantRegion string + wantStripped string + }{ + {"workbuddy-cn/glm-5.3-flash", "workbuddy", "cn", "glm-5.3-flash"}, + {"workbuddy-gl/glm-5.3-flash", "workbuddy", "global", "glm-5.3-flash"}, + {"workbuddy/glm-5.3-flash", "workbuddy", "", "glm-5.3-flash"}, + {"qoder-cn/glm-5.2", "qoder", "cn", "glm-5.2"}, + {"command/deepseek/deepseek-v4-pro", "command", "", "deepseek/deepseek-v4-pro"}, + } + for _, tc := range cases { + req := translate.ChatRequest{Model: tc.model} + provider, region := ResolveProviderFilter(&req, false) + if provider != tc.wantProvider || region != tc.wantRegion || req.Model != tc.wantStripped { + t.Errorf("ResolveProviderFilter(%q) = (%q, %q, %q), want (%q, %q, %q)", + tc.model, provider, region, req.Model, + tc.wantProvider, tc.wantRegion, tc.wantStripped) + } + } +} + +func TestPrepareStripsRegionPrefixIntoContext(t *testing.T) { + ex := NewChatExecutor(NewPool(nil, nil), "") + got, err := ex.Prepare(PrepareInput{ + Request: translate.ChatRequest{Model: "workbuddy-cn/glm-5.3-flash", Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}}, + Identity: auth.KeyIdentity(accounts.APIKey{ + ID: "key_1", Name: "ci", Providers: []string{"qoder", "workbuddy"}, Enabled: true, + }), + NewRequestID: func() string { return "req_r" }, + Now: time.Date(2026, 9, 18, 12, 0, 0, 0, time.UTC), + }) + if err != nil { + t.Fatal(err) + } + if got.ProviderFilter != "workbuddy" || got.Request.Model != "glm-5.3-flash" || got.PublicModel != "workbuddy-cn/glm-5.3-flash" { + t.Fatalf("provider=%q model=%q publicModel=%q", got.ProviderFilter, got.Request.Model, got.PublicModel) + } + if r := regionFilterFromContext(got.Context); r != "cn" { + t.Fatalf("regionFilterFromContext = %q, want cn", r) + } +} + func TestPrepareStripsCommandPrefix(t *testing.T) { ex := NewChatExecutor(NewPool(nil, nil), "") got, err := ex.Prepare(PrepareInput{ From d0c6b0fecbd139723fdfb13f9e40f54e52867793 Mon Sep 17 00:00:00 2001 From: favo <827982457@qq.com> Date: Sat, 17 Oct 2026 22:31:24 +0800 Subject: [PATCH 2/5] feat(executor): add -auto/ region qualifier that lets the pool pick region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare provider prefix (qoder/, workbuddy/) now defaults to the CN region, so a client that wants cross-region load spreading within one provider had no way to say it. The new -auto/ form (qoder-auto/…, workbuddy-auto/…) pins the provider family while keeping the region unqualified, which triggers the pool's automatic region selection and reuses its session-affinity latch so a route sticks to one region until it thins out. --- internal/executor/prepare.go | 20 +++++++++++++------- internal/executor/prepare_test.go | 29 +++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/internal/executor/prepare.go b/internal/executor/prepare.go index 3efb3e6c..6dcf0d30 100644 --- a/internal/executor/prepare.go +++ b/internal/executor/prepare.go @@ -166,13 +166,18 @@ func (e ChatExecutor) Prepare(in PrepareInput) (PreparedRequest, error) { var providerPrefixes = []string{"qoder/", "workbuddy/", "trae/", "devin/", "command/", "codex/", "zhipu/"} // regionDashSuffixes are the region qualifiers that may be appended to a -// provider prefix with a dash: "workbuddy-cn/", "workbuddy-gl/". -var regionDashSuffixes = []string{"-cn/", "-gl/"} +// provider prefix with a dash: "workbuddy-cn/", "workbuddy-gl/", +// "workbuddy-auto/". "-auto/" is the opt-out: it pins the provider family +// while letting the pool pick the region (the lastRegion latch in pool.go). +var regionDashSuffixes = []string{"-cn/", "-gl/", "-auto/"} // regionAliases maps shorthand region tags to canonical region IDs. +// "auto" maps to "" (no region pin), which triggers the pool's automatic +// region selection instead of a fixed region. var regionAliases = map[string]string{ - "cn": "cn", - "gl": "global", + "cn": "cn", + "gl": "global", + "auto": "", } // regionTag extracts the bare region tag from a dash suffix like "-cn/". @@ -205,9 +210,10 @@ func RejectsBareModel(model string, crossProviderPool bool) bool { // provider family and the canonical region ("" when no region was specified). // // Supported forms: -// - "workbuddy/glm-5.3-flash" → provider=workbuddy, region="" -// - "workbuddy-cn/glm-5.3-flash" → provider=workbuddy, region=cn -// - "workbuddy-gl/glm-5.3-flash" → provider=workbuddy, region=global +// - "workbuddy/glm-5.3-flash" → provider=workbuddy, region="" (no region pin; the pool picks) +// - "qoder-auto/glm-5.2" → provider=qoder, region="" (explicit auto: same as the bare prefix) +// - "workbuddy-cn/glm-5.3-flash" → provider=workbuddy, region=cn +// - "workbuddy-gl/glm-5.3-flash" → provider=workbuddy, region=global // // The region coexists with API-key grants: routeBaseMatches enforces both // independently, so a prefix that conflicts with a grant yields no candidates. diff --git a/internal/executor/prepare_test.go b/internal/executor/prepare_test.go index d2d6ef97..87b0536e 100644 --- a/internal/executor/prepare_test.go +++ b/internal/executor/prepare_test.go @@ -264,8 +264,10 @@ func TestProviderPrefixRecognizesCommand(t *testing.T) { "command/deepseek/deepseek-v4-pro": "command", "command/moonshotai/Kimi-K3": "command", // Region-qualified dash form. - "workbuddy-cn/glm-5.3-flash": "workbuddy", - "workbuddy-gl/glm-5.3-flash": "workbuddy", + "workbuddy-cn/glm-5.3-flash": "workbuddy", + "workbuddy-gl/glm-5.3-flash": "workbuddy", + "workbuddy-auto/glm-5.3-flash": "workbuddy", + "qoder-auto/glm-5.2": "qoder", // A bare org-namespaced Command Code id must NOT be read as a provider. "deepseek/deepseek-v4-pro": "", "moonshotai/Kimi-K3": "", @@ -288,6 +290,8 @@ func TestResolveProviderFilterRegionPrefix(t *testing.T) { {"workbuddy-gl/glm-5.3-flash", "workbuddy", "global", "glm-5.3-flash"}, {"workbuddy/glm-5.3-flash", "workbuddy", "", "glm-5.3-flash"}, {"qoder-cn/glm-5.2", "qoder", "cn", "glm-5.2"}, + {"qoder-auto/glm-5.2", "qoder", "", "glm-5.2"}, + {"workbuddy-auto/glm-5.3-flash", "workbuddy", "", "glm-5.3-flash"}, {"command/deepseek/deepseek-v4-pro", "command", "", "deepseek/deepseek-v4-pro"}, } for _, tc := range cases { @@ -301,6 +305,27 @@ func TestResolveProviderFilterRegionPrefix(t *testing.T) { } } +func TestPrepareStripsAutoPrefix(t *testing.T) { + ex := NewChatExecutor(NewPool(nil, nil), "") + got, err := ex.Prepare(PrepareInput{ + Request: translate.ChatRequest{Model: "qoder-auto/qwen3.8-max", Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}}, + Identity: auth.KeyIdentity(accounts.APIKey{ID: "k", Name: "ci", Providers: []string{"qoder"}, Enabled: true}), + NewRequestID: func() string { return "req_a" }, + }) + if err != nil { + t.Fatalf("Prepare error: %v", err) + } + if got.ProviderFilter != "qoder" { + t.Errorf("ProviderFilter = %q, want qoder", got.ProviderFilter) + } + if got.Request.Model != "qwen3.8-max" { + t.Errorf("Request.Model = %q, want qwen3.8-max (prefix stripped)", got.Request.Model) + } + if r := regionFilterFromContext(got.Context); r != "" { + t.Errorf("region = %q, want empty (auto selects region in pool)", r) + } +} + func TestPrepareStripsRegionPrefixIntoContext(t *testing.T) { ex := NewChatExecutor(NewPool(nil, nil), "") got, err := ex.Prepare(PrepareInput{ From ff359c69e62da59ee252bf18f9ff9ef4b675a368 Mon Sep 17 00:00:00 2001 From: favo <827982457@qq.com> Date: Sun, 18 Oct 2026 19:05:22 +0800 Subject: [PATCH 3/5] docs(changelog): consolidate region-prefix notes One bilingual fragment per PR, per changelog/unreleased/README.md. --- changelog/unreleased/region-qualified-model-prefix.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/changelog/unreleased/region-qualified-model-prefix.md b/changelog/unreleased/region-qualified-model-prefix.md index 8ef3ec9b..6cfe255b 100644 --- a/changelog/unreleased/region-qualified-model-prefix.md +++ b/changelog/unreleased/region-qualified-model-prefix.md @@ -1,7 +1,11 @@ ### English - Region-qualified model IDs: use `workbuddy-cn/glm-5.3-flash` or `workbuddy-gl/glm-5.3-flash` to route a request to CN or Global accounts respectively. Applies to all multi-region providers (WorkBuddy, Qoder). +- A bare provider prefix (`qoder/…`, `workbuddy/…`) now defaults to the CN region instead of letting the pool auto-pick. Use the qualified form (`qoder-gl/…`, `workbuddy-gl/…`) to reach Global accounts explicitly. Bare model names with no prefix still auto-route across regions as before. +- New `-auto/` qualifier (`qoder-auto/…`, `workbuddy-auto/…`) pins the provider family while letting the pool pick the region — useful for cross-region load spreading within one provider. It reuses the pool's session-affinity latch so a route sticks to one region until it thins out. ### 中文 - 区域限定模型 ID:使用 `workbuddy-cn/glm-5.3-flash` 或 `workbuddy-gl/glm-5.3-flash` 可将请求分别路由到 CN 或 Global 账户。适用于所有多区域 provider(WorkBuddy、Qoder)。 +- 不带区域限定的供应商前缀(`qoder/…`、`workbuddy/…`)现在默认路由到 CN 区域,而非由池自动分配。如需 Global 账户请显式使用 `qoder-gl/…`、`workbuddy-gl/…`。不带任何前缀的裸模型名仍按原逻辑跨区域自动路由。 +- 新增 `-auto/` 限定形式(`qoder-auto/…`、`workbuddy-auto/…`):固定供应商但不限定区域,由池自动选择区域,适合在单个 provider 内跨区域分摊负载。复用池的会话粘性机制,路由会粘在一个区域直到该区域候选不足才切换。 From 07261a1ea39e994cb96ee18504c4121af5d682e5 Mon Sep 17 00:00:00 2001 From: favo <827982457@qq.com> Date: Sun, 18 Oct 2026 19:06:44 +0800 Subject: [PATCH 4/5] feat(executor): thread model region prefix through route loop Add the WithRegionFilter/regionFilterFromContext context helpers and let newRouteLoop prefer an explicit model-name region over key-grant and pin-derived regions. These helpers were previously introduced alongside an unrelated quota change; they belong with the region-qualified prefix feature. --- internal/executor/chat.go | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/internal/executor/chat.go b/internal/executor/chat.go index 778b9fb9..a819722b 100644 --- a/internal/executor/chat.go +++ b/internal/executor/chat.go @@ -117,6 +117,23 @@ func allowedProvidersFrom(ctx context.Context) []string { return providers } +type regionFilterKey struct{} + +func WithRegionFilter(ctx context.Context, region string) context.Context { + if strings.TrimSpace(region) == "" { + return ctx + } + return context.WithValue(ctx, regionFilterKey{}, region) +} + +func regionFilterFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + region, _ := ctx.Value(regionFilterKey{}).(string) + return region +} + func requestContextDone(ctx context.Context, err error) bool { if err == nil { return false @@ -737,12 +754,19 @@ type routeLoop struct { func (e ChatExecutor) newRouteLoop(ctx context.Context, prefer, providerFilter string, req translate.ChatRequest) routeLoop { prefer, providerFilter, regionFilter, routing := e.prepareRouting(ctx, prefer, providerFilter, req) allowed := allowedProvidersFrom(ctx) - // A key whose grants narrow the selected family to exactly one region - // pre-seeds the region filter: scheduling never even considers accounts - // of other regions — including a pin to an out-of-grant account (the - // grant overrides the pinned account's region, so the pin falls back - // into the granted region) and every failover hop. - if providerFilter != "" { + // A model-name region prefix (e.g. "workbuddy-cn/model") sets an explicit + // region that overrides sticky-session and pin-derived regions. It + // coexists with key grants: routeBaseMatches enforces both filters + // independently, so a prefix that conflicts with a grant yields no + // candidates and a clean error. + if modelRegion := regionFilterFromContext(ctx); modelRegion != "" { + regionFilter = modelRegion + } else if providerFilter != "" { + // A key whose grants narrow the selected family to exactly one region + // pre-seeds the region filter: scheduling never even considers accounts + // of other regions — including a pin to an out-of-grant account (the + // grant overrides the pinned account's region, so the pin falls back + // into the granted region) and every failover hop. if granted, narrowed := keyGrantedSingleRegion(providerFilter, allowed); narrowed { regionFilter = granted } From 79839f0e3f809cc863e64c03d237259113095f0c Mon Sep 17 00:00:00 2001 From: favo <827982457@qq.com> Date: Mon, 19 Oct 2026 12:51:30 +0800 Subject: [PATCH 5/5] fix(executor): keep bare provider prefix region-agnostic per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review on #311: a bare prefix (qoder/, workbuddy/) must not default to cn — international-only deployments would lose all routes. The default-cn commit is dropped; bare prefix and -auto/ now behave identically (provider pinned, region chosen by the pool), with -cn/ and -gl/ as the optional explicit qualifiers. The compat/s01/regression test expectations return to their original form as a result. New coverage: TestPickRouteBarePrefixMatchesGlobalOnlyPool (global-only pool routes a bare qoder/ request; explicit -cn/ does not) and TestPrepareBarePrefixSetsNoRegionFilter. --- .../region-qualified-model-prefix.md | 8 ++++---- internal/executor/pool_route_test.go | 19 ++++++++++++++++++ internal/executor/prepare_test.go | 20 +++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/changelog/unreleased/region-qualified-model-prefix.md b/changelog/unreleased/region-qualified-model-prefix.md index 6cfe255b..60d3e3e9 100644 --- a/changelog/unreleased/region-qualified-model-prefix.md +++ b/changelog/unreleased/region-qualified-model-prefix.md @@ -1,11 +1,11 @@ ### English - Region-qualified model IDs: use `workbuddy-cn/glm-5.3-flash` or `workbuddy-gl/glm-5.3-flash` to route a request to CN or Global accounts respectively. Applies to all multi-region providers (WorkBuddy, Qoder). -- A bare provider prefix (`qoder/…`, `workbuddy/…`) now defaults to the CN region instead of letting the pool auto-pick. Use the qualified form (`qoder-gl/…`, `workbuddy-gl/…`) to reach Global accounts explicitly. Bare model names with no prefix still auto-route across regions as before. -- New `-auto/` qualifier (`qoder-auto/…`, `workbuddy-auto/…`) pins the provider family while letting the pool pick the region — useful for cross-region load spreading within one provider. It reuses the pool's session-affinity latch so a route sticks to one region until it thins out. +- A bare provider prefix (`qoder/…`, `workbuddy/…`) keeps its existing behavior: no region is pinned and the pool auto-picks across regions, so international-only setups keep working unchanged. Bare model names with no prefix still auto-route across regions as before. +- New optional `-auto/` qualifier (`qoder-auto/…`, `workbuddy-auto/…`): an explicit spelling of the bare-prefix behavior — it pins the provider family while letting the pool pick the region, useful for cross-region load spreading within one provider. It reuses the pool's session-affinity latch so a route sticks to one region until it thins out. ### 中文 - 区域限定模型 ID:使用 `workbuddy-cn/glm-5.3-flash` 或 `workbuddy-gl/glm-5.3-flash` 可将请求分别路由到 CN 或 Global 账户。适用于所有多区域 provider(WorkBuddy、Qoder)。 -- 不带区域限定的供应商前缀(`qoder/…`、`workbuddy/…`)现在默认路由到 CN 区域,而非由池自动分配。如需 Global 账户请显式使用 `qoder-gl/…`、`workbuddy-gl/…`。不带任何前缀的裸模型名仍按原逻辑跨区域自动路由。 -- 新增 `-auto/` 限定形式(`qoder-auto/…`、`workbuddy-auto/…`):固定供应商但不限定区域,由池自动选择区域,适合在单个 provider 内跨区域分摊负载。复用池的会话粘性机制,路由会粘在一个区域直到该区域候选不足才切换。 +- 不带区域限定的供应商前缀(`qoder/…`、`workbuddy/…`)行为不变:不锁定区域,由池跨区域自动选择,只有国际账号的部署不受影响。不带任何前缀的裸模型名也仍按原逻辑跨区域自动路由。 +- 新增可选的 `-auto/` 限定形式(`qoder-auto/…`、`workbuddy-auto/…`):与裸前缀行为一致的显式写法——固定供应商但不限定区域,由池自动选择区域,适合在单个 provider 内跨区域分摊负载。复用池的会话粘性机制,路由会粘在一个区域直到该区域候选不足才切换。 diff --git a/internal/executor/pool_route_test.go b/internal/executor/pool_route_test.go index 728c846b..614308d4 100644 --- a/internal/executor/pool_route_test.go +++ b/internal/executor/pool_route_test.go @@ -174,6 +174,25 @@ func TestPickRouteCandidateCountShrinksAfterRegionPin(t *testing.T) { } } +func TestPickRouteBarePrefixMatchesGlobalOnlyPool(t *testing.T) { + // A bare "qoder/model" request carries no region pin, so a pool with only + // international (global) accounts must still route; only an explicit -cn/ + // pin may exclude them. + p := NewPool(nil, nil) + p.Upsert(Item{ID: "g1", URL: "http://g1", Provider: "qoder", Region: "global", Runtime: "child_process"}) + + got, ok := p.PickRoute(RouteQuery{ProviderFilter: "qoder"}) + if !ok || got.ID != "g1" { + t.Fatalf("bare qoder prefix must route to the global-only account, got %+v ok=%v", got, ok) + } + if n := p.LenRoute(RouteQuery{ProviderFilter: "qoder"}); n != 1 { + t.Fatalf("unpinned qoder candidates = %d, want 1", n) + } + if _, ok := p.PickRoute(RouteQuery{ProviderFilter: "qoder", RegionFilter: "cn"}); ok { + t.Fatal("an explicit -cn/ pin must not match a global-only pool") + } +} + func TestPickRouteHonorsAPIKeyAllowlist(t *testing.T) { p := NewPool(nil, nil) p.Upsert(Item{ID: "q1", URL: "http://q1", Provider: "qoder", Runtime: "child_process"}) diff --git a/internal/executor/prepare_test.go b/internal/executor/prepare_test.go index 87b0536e..74ae52fb 100644 --- a/internal/executor/prepare_test.go +++ b/internal/executor/prepare_test.go @@ -347,6 +347,26 @@ func TestPrepareStripsRegionPrefixIntoContext(t *testing.T) { } } +func TestPrepareBarePrefixSetsNoRegionFilter(t *testing.T) { + // A bare "qoder/model" must not default to cn: no region filter reaches the + // route loop, so international-only pools still route. + ex := NewChatExecutor(NewPool(nil, nil), "") + got, err := ex.Prepare(PrepareInput{ + Request: translate.ChatRequest{Model: "qoder/qwen3.8-max", Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}}, + Identity: auth.KeyIdentity(accounts.APIKey{ID: "k", Name: "ci", Providers: []string{"qoder"}, Enabled: true}), + NewRequestID: func() string { return "req_b" }, + }) + if err != nil { + t.Fatalf("Prepare error: %v", err) + } + if got.ProviderFilter != "qoder" || got.Request.Model != "qwen3.8-max" { + t.Errorf("provider=%q model=%q, want qoder / qwen3.8-max", got.ProviderFilter, got.Request.Model) + } + if r := regionFilterFromContext(got.Context); r != "" { + t.Errorf("region = %q, want empty (bare prefix must not pin cn)", r) + } +} + func TestPrepareStripsCommandPrefix(t *testing.T) { ex := NewChatExecutor(NewPool(nil, nil), "") got, err := ex.Prepare(PrepareInput{