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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Share one provider, region, and model match across API-key allowlists, sticky sessions, and pool picks, and reuse the same chat preflight for `/v1` and compatibility routes
- Canonicalize mixed-case provider IDs when storing accounts and looking up in-process adapters so a routed WorkBuddy account cannot miss its adapter
- Document provider-prefixed model IDs on the Access page, and keep named API keys limited to those providers for both chat routing and `/v1/models`

### 中文

- API key 白名单、会话粘滞和池调度共用同一套供应商 / 区域 / 模型匹配;`/v1` 与兼容接口共用同一套请求预检
- 账号入库和 in-process adapter 查找都按规范供应商 ID,避免 `WorkBuddy` 这类大小写混写选中后找不到执行器
- API 接入页说明可用模型前缀指定供应商;客户端密钥在对话调度和 `/v1/models` 上都只看到允许的供应商

## 0.3.0 - 2026-09-06

### English
Expand Down
18 changes: 10 additions & 8 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const messages: Record<Lang, Dict> = {
routesHint: 'OpenAI / Anthropic-compatible routes',
clientConfig: 'Client configuration',
clientConfigHint: 'Use the same console key as Bearer token',
providerPrefixHint: 'Pin a provider with a qoder/, workbuddy/, or trae/ model prefix. A named API key can also limit which providers are listed and routed. Bare IDs follow the cross-provider pool setting on System.',
authentication: 'Authentication',
curlExample: 'curl example',
quickTestHint: 'Send a real request through the selected account',
Expand Down Expand Up @@ -336,13 +337,13 @@ export const messages: Record<Lang, Dict> = {
endpointOpenAI: 'OpenAI Compatible',
endpointChat: 'Chat Completions',
endpointBaseHint: 'Use this path as the base URL in OpenAI-compatible clients.',
endpointChatHint: 'OpenAI-compatible chat requests with streaming, images, and tools.',
endpointChatHint: 'OpenAI-compatible chat. Prefix the model ID to pin one provider, for example qoder/glm-5.2.',
endpointMessages: 'Anthropic Messages',
endpointMessagesHint: 'Stateless Anthropic Messages adapter with text, images, and tools.',
endpointMessagesHint: 'Stateless Anthropic Messages. Use a provider-prefixed model ID to pin one family.',
endpointResponses: 'OpenAI Responses',
endpointResponsesHint: 'Stateless OpenAI Responses adapter with text, images, and tools.',
endpointResponsesHint: 'Stateless OpenAI Responses. Use a provider-prefixed model ID to pin one family.',
endpointModels: 'Models',
endpointModelsHint: 'List the public model IDs currently available through the proxy.',
endpointModelsHint: 'Public model IDs. Named API keys only see models from their allowed providers.',
endpointHealth: 'Health',
endpointHealthHint: 'Check whether the local proxy is responding.',
endpointCount: '{count} API routes',
Expand Down Expand Up @@ -589,6 +590,7 @@ export const messages: Record<Lang, Dict> = {
routesHint: 'OpenAI / Anthropic 兼容端点',
clientConfig: '客户端配置',
clientConfigHint: '使用与控制台相同的密钥作为 Bearer Token',
providerPrefixHint: '用 qoder/、workbuddy/、trae/ 前缀指定供应商。客户端 API 密钥也可以限制可见和可调度的供应商。不带前缀时是否跨供应商,由系统设置里的模型池决定。',
authentication: '认证方式',
curlExample: 'curl 示例',
quickTestHint: '通过所选账号发送一条真实请求',
Expand Down Expand Up @@ -912,13 +914,13 @@ export const messages: Record<Lang, Dict> = {
endpointOpenAI: 'OpenAI 兼容',
endpointChat: 'Chat Completions',
endpointBaseHint: '在 OpenAI 兼容客户端中,将此地址作为 Base URL。',
endpointChatHint: '支持流式、图片与工具调用的 OpenAI 兼容对话接口。',
endpointChatHint: 'OpenAI 兼容对话。模型 ID 加前缀即可指定供应商,例如 qoder/glm-5.2。',
endpointMessages: 'Anthropic Messages',
endpointMessagesHint: '无状态 Anthropic Messages 适配层,支持文本、图片和工具调用。',
endpointMessagesHint: '无状态 Anthropic Messages。使用带供应商前缀的模型 ID 即可钉死一家。',
endpointResponses: 'OpenAI Responses',
endpointResponsesHint: '无状态 OpenAI Responses 适配层,支持文本、图片和工具调用。',
endpointResponsesHint: '无状态 OpenAI Responses。使用带供应商前缀的模型 ID 即可钉死一家。',
endpointModels: '模型列表',
endpointModelsHint: '查看当前代理可用的公开模型 ID。',
endpointModelsHint: '公开模型 ID。客户端 API 密钥只会看到其允许供应商的模型。',
endpointHealth: '健康检查',
endpointHealthHint: '检查本地代理是否正在响应。',
endpointCount: '{count} 个 API 端点',
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/AccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ export function AccessPage() {
</div>
</section>

<p data-gsap-reveal className="max-w-3xl text-sm leading-6 text-muted">{t('providerPrefixHint')}</p>

<EndpointList access={overview?.access} />

<Card data-gsap-reveal className="overflow-hidden p-0">
Expand Down
55 changes: 46 additions & 9 deletions internal/accounts/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,31 @@ type RouteQuery struct {
Excluded map[string]struct{}
}

func itemRegion(item Item) string {
region := strings.ToLower(strings.TrimSpace(item.Region))
// NormalizeProviderFamily maps a stored or requested provider ID onto the
// canonical family name. An empty value is Qoder, matching the historical
// default account family.
func NormalizeProviderFamily(provider string) string {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return "qoder"
}
return provider
}

// NormalizeRegion maps a stored or requested region onto the canonical
// routing region. An empty value is global.
func NormalizeRegion(region string) string {
region = strings.ToLower(strings.TrimSpace(region))
if region == "" {
return "global"
}
return region
}

func itemRegion(item Item) string {
return NormalizeRegion(item.Region)
}

func CanonicalModelID(model string) string {
key := strings.ToLower(strings.TrimSpace(model))
key = strings.NewReplacer("_", "-", " ", "-").Replace(key)
Expand Down Expand Up @@ -214,6 +231,20 @@ func itemCouldServeModel(item Item, publicModel string) bool {
return item.Models == nil
}

// ItemHasModel reports whether this account can serve publicModel on a live
// pick. Cooling empty-catalog accounts fail closed here so they do not occupy
// the route just because a catalog fetch failed.
func ItemHasModel(item Item, publicModel string) bool {
return itemHasModel(item, publicModel)
}

// ItemCouldServeModel reports whether this account belongs on a model route at
// all, including cooling unknown-catalog accounts. Sticky routing uses this so
// a bound cooling account still pins provider/region before PickRoute escapes.
func ItemCouldServeModel(item Item, publicModel string) bool {
return itemCouldServeModel(item, publicModel)
}

func itemHasModel(item Item, publicModel string) bool {
want := routeModel(publicModel)
if want == "" {
Expand Down Expand Up @@ -252,14 +283,14 @@ func NativeModelID(item Item, publicModel string) string {
return strings.TrimSpace(publicModel)
}

func providerAllowed(provider string, allowed []string) bool {
// ProviderAllowed reports whether an account family may be used under an API
// key allowlist. An empty allowlist means every family. An empty provider is
// treated as Qoder, the same default PickRoute uses.
func ProviderAllowed(provider string, allowed []string) bool {
if len(allowed) == 0 {
return true
}
family := strings.ToLower(strings.TrimSpace(provider))
if family == "" {
family = "qoder"
}
family := NormalizeProviderFamily(provider)
for _, item := range allowed {
if strings.ToLower(strings.TrimSpace(item)) == family {
return true
Expand All @@ -268,6 +299,10 @@ func providerAllowed(provider string, allowed []string) bool {
return false
}

func providerAllowed(provider string, allowed []string) bool {
return ProviderAllowed(provider, allowed)
}

// NormalizeWeight maps a stored priority onto a scheduling weight. The
// console exposes 1..100 with 50 as the default; anything outside the range
// falls back to the default so a bad row cannot distort rotation.
Expand Down Expand Up @@ -300,10 +335,10 @@ func routeBaseMatches(item Item, q RouteQuery) bool {
if !providerAllowed(item.Provider, q.AllowedProviders) {
return false
}
if q.ProviderFilter != "" && item.Provider != q.ProviderFilter {
if q.ProviderFilter != "" && NormalizeProviderFamily(item.Provider) != NormalizeProviderFamily(q.ProviderFilter) {
return false
}
if q.RegionFilter != "" && itemRegion(item) != strings.ToLower(strings.TrimSpace(q.RegionFilter)) {
if q.RegionFilter != "" && itemRegion(item) != NormalizeRegion(q.RegionFilter) {
return false
}
return true
Expand Down Expand Up @@ -1232,6 +1267,8 @@ func (p *Pool) Upsert(item Item) {
if p == nil || item.ID == "" {
return
}
item.Provider = NormalizeProviderFamily(item.Provider)
item.Region = NormalizeRegion(item.Region)
p.mu.Lock()
defer p.mu.Unlock()
for i := range p.items {
Expand Down
92 changes: 92 additions & 0 deletions internal/accounts/pool_route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,98 @@ func TestPickRouteRespectsProviderFamilyAndCooldown(t *testing.T) {
}
}

func TestNormalizeProviderRegionAndAllowlist(t *testing.T) {
if got := NormalizeProviderFamily(""); got != "qoder" {
t.Fatalf("empty provider = %q", got)
}
if got := NormalizeRegion(""); got != "global" {
t.Fatalf("empty region = %q", got)
}
if !ProviderAllowed("", []string{"qoder"}) {
t.Fatal("empty account provider must match a qoder allowlist")
}
if ProviderAllowed("trae", []string{"qoder"}) {
t.Fatal("trae must not match a qoder allowlist")
}
if !ProviderAllowed("workbuddy", nil) {
t.Fatal("empty allowlist must admit every family")
}
if !ProviderAllowed("Qoder", []string{"qoder"}) || !ProviderAllowed("qoder", []string{"QODER"}) {
t.Fatal("provider allowlists must match case-insensitively")
}

ready := Item{ID: "a", Models: []string{"hy3"}, ProvenModels: []string{"glm-5.2"}}
if !ItemHasModel(ready, "glm-5.2") {
t.Fatal("proven models must satisfy ItemHasModel")
}
if ItemHasModel(Item{ID: "b", Models: []string{"hy3"}}, "glm-5.2") {
t.Fatal("catalog without the model must fail ItemHasModel")
}
coolingUnknown := Item{ID: "c", DownUntil: time.Now().Add(time.Hour)}
if ItemHasModel(coolingUnknown, "glm-5.2") {
t.Fatal("cooling empty catalog must fail ItemHasModel")
}
if !ItemCouldServeModel(coolingUnknown, "glm-5.2") {
t.Fatal("cooling empty catalog must still belong on the model route")
}
}

func TestPickRouteNormalizesProviderAndRegion(t *testing.T) {
p := NewPool(nil, nil)
p.Upsert(Item{ID: "q1", URL: "http://q1", Runtime: "child_process"})
p.Upsert(Item{ID: "q2", URL: "http://q2", Provider: "Qoder", Region: "Global", Runtime: "child_process"})
p.Upsert(Item{ID: "w1", Provider: "WorkBuddy", Region: "CN", Runtime: "in_process"})

qoder, ok := p.PickRoute(RouteQuery{ProviderFilter: "QODER", PreferAccount: "q1"})
if !ok || qoder.ID != "q1" {
t.Fatalf("empty provider must match qoder filter, got %+v ok=%v", qoder, ok)
}
global, ok := p.PickRoute(RouteQuery{ProviderFilter: "qoder", RegionFilter: "GLOBAL", PreferAccount: "q1"})
if !ok || global.ID != "q1" {
t.Fatalf("empty region must match global filter, got %+v ok=%v", global, ok)
}
workbuddy, ok := p.PickRoute(RouteQuery{ProviderFilter: "workbuddy", RegionFilter: "cn"})
if !ok || workbuddy.ID != "w1" {
t.Fatalf("mixed-case provider/region = %+v ok=%v", workbuddy, ok)
}
stored, ok := p.ByID("w1")
if !ok || stored.Provider != "workbuddy" || stored.Region != "cn" {
t.Fatalf("upsert must store canonical provider/region, got %+v", stored)
}
empty, ok := p.ByID("q1")
if !ok || empty.Provider != "qoder" || empty.Region != "global" {
t.Fatalf("empty provider/region must store qoder/global, got %+v", empty)
}
}

func TestPickRouteCandidateCountShrinksAfterRegionPin(t *testing.T) {
p := NewPool(nil, nil)
p.Upsert(Item{ID: "g1", URL: "http://g1", Provider: "qoder", Region: "global", Runtime: "child_process"})
p.Upsert(Item{ID: "g2", URL: "http://g2", Provider: "qoder", Region: "global", Runtime: "child_process"})
p.Upsert(Item{ID: "c1", URL: "http://c1", Provider: "qoder", Region: "cn", Runtime: "child_process"})

open := RouteQuery{ProviderFilter: "qoder"}
if n := p.LenRoute(open); n != 3 {
t.Fatalf("unpinned qoder candidates = %d", n)
}
first, ok := p.PickRoute(open)
if !ok || first.ID != "g1" {
t.Fatalf("first pick = %+v ok=%v", first, ok)
}

pinned := RouteQuery{ProviderFilter: "qoder", RegionFilter: first.Region, Excluded: map[string]struct{}{first.ID: {}}}
if n := p.LenRoute(pinned); n != 1 {
t.Fatalf("after first failure, same-region candidates = %d, want 1", n)
}
if n := p.LenRoute(RouteQuery{ProviderFilter: "qoder", RegionFilter: "cn"}); n != 1 {
t.Fatalf("cn candidates must stay out of the pinned retry set, got %d", n)
}
retry, ok := p.PickRoute(pinned)
if !ok || retry.ID != "g2" {
t.Fatalf("retry pick = %+v ok=%v", retry, ok)
}
}

func TestPickRouteHonorsAPIKeyAllowlist(t *testing.T) {
p := NewPool(nil, nil)
p.Upsert(Item{ID: "q1", URL: "http://q1", Provider: "qoder", Runtime: "child_process"})
Expand Down
40 changes: 40 additions & 0 deletions internal/api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,46 @@ func TestNamedAPIKeyCannotManageConsoleOrKeys(t *testing.T) {
}
}

func TestNamedAPIKeyModelsListOnlyIncludesAllowedProviders(t *testing.T) {
srv := New(config.Config{
Host: "127.0.0.1", Port: 3010, ProxyAPIKey: "secret",
QoderHome: t.TempDir(), DataDir: t.TempDir(),
})
defer srv.Close()
created, err := srv.manager.Store().CreateAPIKey(context.Background(), accounts.CreateAPIKey{
Name: "ci", Providers: []string{"qoder"}, Enabled: true,
})
if err != nil {
t.Fatal(err)
}
srv.pool.Upsert(accounts.Item{ID: "wb1", Provider: "workbuddy", Runtime: string(providers.RuntimeInProcess)})
srv.providers.Register(providers.Adapter{ID: "workbuddy", Models: &countingCatalog{models: []providers.ModelInfo{{
NativeModel: "glm-5.2", PublicModel: "glm-5.2", DisplayName: "GLM",
}}}})

req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
req.Header.Set("Authorization", "Bearer "+created.Secret)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("named key GET /v1/models = %d %s", rec.Code, rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte(`"workbuddy"`)) || bytes.Contains(rec.Body.Bytes(), []byte(`"owned_by":"workbuddy"`)) {
t.Fatalf("qoder-only key must not list workbuddy models: %s", rec.Body.String())
}

console := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
console.Header.Set("Authorization", "Bearer secret")
consoleRec := httptest.NewRecorder()
srv.Handler().ServeHTTP(consoleRec, console)
if consoleRec.Code != http.StatusOK {
t.Fatalf("console GET /v1/models = %d %s", consoleRec.Code, consoleRec.Body.String())
}
if !bytes.Contains(consoleRec.Body.Bytes(), []byte(`"workbuddy"`)) {
t.Fatalf("console key must still list workbuddy models: %s", consoleRec.Body.String())
}
}

func TestAPIKeysCRUDAndConsoleKeyPrefix(t *testing.T) {
srv := New(config.Config{
Host: "127.0.0.1", Port: 3010, ProxyAPIKey: "secret",
Expand Down
Loading
Loading