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
11 changes: 11 additions & 0 deletions changelog/unreleased/region-qualified-model-prefix.md
Original file line number Diff line number Diff line change
@@ -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 内跨区域分摊负载。复用池的会话粘性机制,路由会粘在一个区域直到该区域候选不足才切换。
4 changes: 2 additions & 2 deletions internal/accounts/ids.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion internal/api/compat_wrappers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 30 additions & 6 deletions internal/executor/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
19 changes: 19 additions & 0 deletions internal/executor/pool_route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
68 changes: 60 additions & 8 deletions internal/executor/prepare.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 ""
}
Expand All @@ -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 {
Expand Down
93 changes: 93 additions & 0 deletions internal/executor/prepare_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand All @@ -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{
Expand Down
Loading