diff --git a/changelog/unreleased/region-qualified-model-prefix.md b/changelog/unreleased/region-qualified-model-prefix.md new file mode 100644 index 00000000..60d3e3e9 --- /dev/null +++ b/changelog/unreleased/region-qualified-model-prefix.md @@ -0,0 +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/…`) 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/…`)行为不变:不锁定区域,由池跨区域自动选择,只有国际账号的部署不受影响。不带任何前缀的裸模型名也仍按原逻辑跨区域自动路由。 +- 新增可选的 `-auto/` 限定形式(`qoder-auto/…`、`workbuddy-auto/…`):与裸前缀行为一致的显式写法——固定供应商但不限定区域,由池自动选择区域,适合在单个 provider 内跨区域分摊负载。复用池的会话粘性机制,路由会粘在一个区域直到该区域候选不足才切换。 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/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 } 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.go b/internal/executor/prepare.go index 68a369b2..6dcf0d30 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,38 @@ 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/", +// "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", + "auto": "", +} + +// 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 +205,42 @@ 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="" (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. +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..74ae52fb 100644 --- a/internal/executor/prepare_test.go +++ b/internal/executor/prepare_test.go @@ -263,6 +263,11 @@ 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", + "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": "", @@ -274,6 +279,94 @@ 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"}, + {"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 { + 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 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{ + 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 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{