Skip to content

Commit e8dba0e

Browse files
fix(coordinator): route catalog keys to equivalent HF model ids (#900) (#905)
* fix(coordinator): route buyer catalog keys to equivalent HF model ids ModelKnown and buyer model matching only compared served HuggingFace ids, so rate-card keys like openai/gpt-oss-20b 404'd while the HF id routed. Reuse NormalizeModelKey equivalence for known-model and selection matching (#900). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(coordinator): scope catalog aliases and harden ModelKnown Namespace-blind gpt-oss/meta-llama/nemotron collapses let foreign known namespaces spoof catalog keys once ModelsEquivalent drove routing. Restrict magic-prefix rewrites, hoist ModelKnown query normalization, and keep Pillar-A hash buckets on literal ModelID. Co-authored-by: Cursor <cursoragent@cursor.com> * test(buyer): cover catalog-key pin and class-member equivalence Lock sticky-pin validation and model-class membership against the Co-authored-by: Cursor <cursoragent@cursor.com> #900 alias rules, including foreign-namespace spoof negatives. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1f7a8e5 commit e8dba0e

7 files changed

Lines changed: 258 additions & 13 deletions

File tree

phase4-coordinator/internal/billing/formula.go

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,55 @@ func NormalizeModelKey(model string) string {
7777
switch {
7878
case namespace == "meta-llama" && strings.HasPrefix(key, "llama-"):
7979
return "meta-llama/" + key
80-
case strings.HasPrefix(key, "meta-llama-"):
80+
// Magic-prefix collapses are namespace-scoped: only the empty /
81+
// mlx-community / canonical-vendor namespaces may rewrite into the
82+
// catalog key. Other known namespaces (qwen/, google/, …) must not
83+
// spoof openai/gpt-oss-20b (or meta-llama / nemotron) via body
84+
// prefix alone — that became load-bearing for routing in #900.
85+
case servedAliasNamespace(namespace, "meta-llama") && strings.HasPrefix(key, "meta-llama-"):
8186
return "meta-llama/" + strings.TrimPrefix(key, "meta-")
82-
case strings.HasPrefix(key, "nvidia-nemotron-"):
87+
case servedAliasNamespace(namespace, "nvidia") && strings.HasPrefix(key, "nvidia-nemotron-"):
8388
return strings.TrimPrefix(key, "nvidia-")
84-
case strings.HasPrefix(key, "gpt-oss-"):
89+
case servedAliasNamespace(namespace, "openai") && strings.HasPrefix(key, "gpt-oss-"):
8590
return "openai/" + key
8691
default:
8792
return key
8893
}
8994
}
9095

96+
// ModelsEquivalent reports whether two buyer/provider model identifiers
97+
// refer to the same rate-card / catalog key after NormalizeModelKey.
98+
// Routing uses this so catalog keys (e.g. openai/gpt-oss-20b) match the
99+
// served HuggingFace ids (e.g. mlx-community/gpt-oss-20b-MXFP4-Q8)
100+
// without registering duplicate provider identities (issue #900).
101+
func ModelsEquivalent(a, b string) bool {
102+
if a == "" || b == "" {
103+
return false
104+
}
105+
if strings.EqualFold(a, b) {
106+
return true
107+
}
108+
na, nb := NormalizeModelKey(a), NormalizeModelKey(b)
109+
if na == "" || nb == "" {
110+
return false
111+
}
112+
return na == nb
113+
}
114+
115+
// MatchesNormalizedKey reports whether model aligns with an already-
116+
// computed NormalizeModelKey(query). Used by ModelKnown to avoid
117+
// re-normalizing the buyer-supplied query on every seen-model scan.
118+
func MatchesNormalizedKey(model, normalizedQuery string) bool {
119+
if model == "" || normalizedQuery == "" {
120+
return false
121+
}
122+
if strings.EqualFold(model, normalizedQuery) {
123+
return true
124+
}
125+
got := NormalizeModelKey(model)
126+
return got != "" && got == normalizedQuery
127+
}
128+
91129
func knownModelNamespace(namespace string) bool {
92130
switch namespace {
93131
case "mlx-community", "openai", "google", "meta-llama", "nvidia", "qwen":
@@ -97,6 +135,19 @@ func knownModelNamespace(namespace string) bool {
97135
}
98136
}
99137

138+
// servedAliasNamespace is true when a magic-prefix rewrite may collapse
139+
// into canonicalVendor's catalog key. Empty (no repo prefix) and
140+
// mlx-community (typical served HF id) are the only non-canonical
141+
// sources allowed; foreign known namespaces cannot spoof the alias.
142+
func servedAliasNamespace(namespace, canonicalVendor string) bool {
143+
switch namespace {
144+
case "", "mlx-community", canonicalVendor:
145+
return true
146+
default:
147+
return false
148+
}
149+
}
150+
100151
func ParseMultiplierPPM(v float64) int64 {
101152
return int64(math.Round(v * float64(globalMultiplierDenom)))
102153
}

phase4-coordinator/internal/billing/formula_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,34 @@ func TestRateFor_FallsBackToDefault(t *testing.T) {
7878
}
7979
}
8080

81+
func TestModelsEquivalent_CatalogKeyMatchesServedHFID(t *testing.T) {
82+
cases := []struct {
83+
a, b string
84+
want bool
85+
}{
86+
{"openai/gpt-oss-20b", "mlx-community/gpt-oss-20b-MXFP4-Q8", true},
87+
{"mlx-community/gpt-oss-20b-MXFP4-Q8", "openai/gpt-oss-20b", true},
88+
{"OPENAI/GPT-OSS-20B", "mlx-community/gpt-oss-20b-MXFP4-Q8", true},
89+
{"openai/gpt-oss-20b", "openai/gpt-oss-20b", true},
90+
{"openai/gpt-oss-20b", "mlx-community/Qwen3-32B-4bit", false},
91+
{"", "openai/gpt-oss-20b", false},
92+
{"openai/gpt-oss-20b", "", false},
93+
{"", "", false},
94+
{"qwen3-32b", "mlx-community/Qwen3-32B-4bit", true},
95+
// Foreign known namespaces must not spoof catalog vendors (#900 audit).
96+
{"qwen/gpt-oss-20b", "openai/gpt-oss-20b", false},
97+
{"google/gpt-oss-20b", "openai/gpt-oss-20b", false},
98+
{"qwen/meta-llama-3.1-8b-instruct-4bit", "meta-llama/Llama-3.1-8B-Instruct-4bit", false},
99+
{"qwen/nvidia-nemotron-3-nano-30b-a3b", "nvidia/nemotron-3-nano-30b-a3b", false},
100+
{"openai/nvidia-nemotron-3-nano-30b-a3b", "nvidia/nemotron-3-nano-30b-a3b", false},
101+
}
102+
for _, tc := range cases {
103+
if got := ModelsEquivalent(tc.a, tc.b); got != tc.want {
104+
t.Fatalf("ModelsEquivalent(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
105+
}
106+
}
107+
}
108+
81109
func TestRateFor_UnknownNamespaceDoesNotNormalizeToKnownModel(t *testing.T) {
82110
rateA := RateCardEntry{PromptCreditsPerMtok: 100, CompletionCreditsPerMtok: 200}
83111
rateD := RateCardEntry{PromptCreditsPerMtok: 300, CompletionCreditsPerMtok: 400}

phase4-coordinator/internal/buyer/server.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1728,7 +1728,11 @@ func (s *Server) applyHashVerification(entry *modelEntry, providers []pool.Provi
17281728
modelProviders := make([]pool.Provider, 0)
17291729
catalogUnavailable := false
17301730
for _, p := range providers {
1731-
if !hasAvailableSlot(p) || !modelIDEqual(p.ModelID, entry.ID) {
1731+
// Hash-verification buckets are per served binary (literal ModelID),
1732+
// not per catalog-key billing equivalence. modelIDEqual/#900 aliases
1733+
// must not cross-contaminate /v1/models Pillar-A status across
1734+
// distinct HF ids that share a rate-card key.
1735+
if !hasAvailableSlot(p) || !strings.EqualFold(p.ModelID, entry.ID) {
17321736
continue
17331737
}
17341738
status := s.effectiveHashStatus(p, cfg)
@@ -6595,7 +6599,11 @@ func (c *eligibilityCtx) QuotaPermits(p pool.Provider) bool {
65956599
}
65966600

65976601
func modelIDEqual(a, b string) bool {
6598-
return strings.EqualFold(a, b)
6602+
// Catalog-key ↔ served HF-id equivalence via rate-card normalization
6603+
// (issue #900). EqualFold alone rejects openai/gpt-oss-20b against
6604+
// mlx-community/gpt-oss-20b-MXFP4-Q8 even though pricing already
6605+
// collapses both to the same catalog key.
6606+
return billing.ModelsEquivalent(a, b)
65996607
}
66006608

66016609
func (s *Server) zeroTokenFault(end providerws.InferenceResponseEnd, finishReason string) bool {

phase4-coordinator/internal/buyer/server_internal_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,55 @@ import (
88
"strings"
99
"testing"
1010

11+
"github.com/augstar/macprovider-coordinator/internal/config"
1112
"github.com/augstar/macprovider-coordinator/internal/pool"
1213
providerws "github.com/augstar/macprovider-coordinator/internal/ws"
1314
)
1415

16+
func TestValidatePinnedProviderAcceptsCatalogKeyAlias(t *testing.T) {
17+
const hfID = "mlx-community/gpt-oss-20b-MXFP4-Q8"
18+
const catalogKey = "openai/gpt-oss-20b"
19+
p := pool.Provider{
20+
ProviderID: "p1",
21+
AssignedID: "s1",
22+
ModelID: hfID,
23+
State: pool.StateReady,
24+
SlotsFree: 1,
25+
SlotsTotal: 1,
26+
MaxContextTokens: 20000,
27+
}
28+
if _, routeErr := validatePinnedProvider(p, catalogKey, 10, "Pinned provider not available"); routeErr != nil {
29+
t.Fatalf("catalog-key pin rejected: status=%d code=%s msg=%s", routeErr.status, routeErr.code, routeErr.message)
30+
}
31+
if _, routeErr := validatePinnedProvider(p, hfID, 10, "Pinned provider not available"); routeErr != nil {
32+
t.Fatalf("HF-id pin rejected: status=%d code=%s msg=%s", routeErr.status, routeErr.code, routeErr.message)
33+
}
34+
if _, routeErr := validatePinnedProvider(p, "qwen/gpt-oss-20b", 10, "Pinned provider not available"); routeErr == nil {
35+
t.Fatal("foreign-namespace spoof must not satisfy pinned provider model match")
36+
}
37+
if _, routeErr := validatePinnedProvider(p, "openai/gpt-oss-120b", 10, "Pinned provider not available"); routeErr == nil {
38+
t.Fatal("unrelated catalog key must not satisfy pinned provider model match")
39+
}
40+
}
41+
42+
func TestProviderMatchesRequestClassMemberCatalogKeyAlias(t *testing.T) {
43+
s := &Server{}
44+
class := &config.ModelClassConfig{
45+
Objective: "cheap",
46+
Members: []string{"openai/gpt-oss-20b"},
47+
}
48+
p := pool.Provider{ModelID: "mlx-community/gpt-oss-20b-MXFP4-Q8"}
49+
if !s.providerMatchesRequest(p, "fast-class", class) {
50+
t.Fatal("class member catalog key must match served HF id")
51+
}
52+
if s.providerMatchesRequest(p, "fast-class", &config.ModelClassConfig{
53+
Objective: "cheap",
54+
Members: []string{"qwen/gpt-oss-20b"},
55+
}) {
56+
t.Fatal("foreign-namespace class member must not match openai-served HF id")
57+
}
58+
}
59+
1560
// TestNoPriorDispatchResponseWriterMarks pins the coordinator half of the
1661
// item-18 fix: the central noPriorDispatchResponseWriter stamps the POSITIVE
1762
// X-MacProvider-Settlement-No-Prior-Dispatch marker on the first response write

phase4-coordinator/internal/buyer/server_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1616,6 +1616,55 @@ func TestChatCompletionsRoutesNonStreamingRequest(t *testing.T) {
16161616
}
16171617
}
16181618

1619+
// TestChatCompletionsRoutesCatalogKeyToHFModelID pins issue #900: a
1620+
// buyer request using the rate-card / catalog key must route to the
1621+
// provider serving the equivalent HuggingFace model id, and the
1622+
// upstream dispatch body must carry the provider's ModelID.
1623+
func TestChatCompletionsRoutesCatalogKeyToHFModelID(t *testing.T) {
1624+
const hfID = "mlx-community/gpt-oss-20b-MXFP4-Q8"
1625+
const catalogKey = "openai/gpt-oss-20b"
1626+
1627+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1628+
var req map[string]any
1629+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1630+
t.Fatalf("upstream request json: %v", err)
1631+
}
1632+
if req["model"] != hfID {
1633+
t.Fatalf("upstream model = %v, want rewritten provider ModelID %q", req["model"], hfID)
1634+
}
1635+
w.Header().Set("Content-Type", "application/json")
1636+
_, _ = w.Write([]byte(`{"id":"chatcmpl-oss","object":"chat.completion","created":1716768000,"model":"` + hfID + `","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":1,"total_tokens":5}}`))
1637+
}))
1638+
defer upstream.Close()
1639+
1640+
registry := pool.NewRegistry([]config.ProviderConfig{{ProviderID: "p1", EndpointURL: upstream.URL}})
1641+
registerWithEndpoint(registry, "p1", "session-1", hfID, pool.StateReady, 20000, 1, upstream.URL, 20)
1642+
server := buyer.NewServer(registry, zerolog.Nop(), time.Unix(1716768000, 0))
1643+
1644+
catalogRR := postChat(t, server, []byte(`{"model":"`+catalogKey+`","messages":[{"role":"user","content":"hello"}],"stream":false}`), nil)
1645+
if catalogRR.Code != http.StatusOK {
1646+
t.Fatalf("catalog-key status = %d, body=%s", catalogRR.Code, catalogRR.Body.String())
1647+
}
1648+
if catalogRR.Header().Get("X-MacProvider-Provider") != "p1" {
1649+
t.Fatalf("catalog-key provider header = %q", catalogRR.Header().Get("X-MacProvider-Provider"))
1650+
}
1651+
1652+
hfRR := postChat(t, server, []byte(`{"model":"`+hfID+`","messages":[{"role":"user","content":"hello"}],"stream":false}`), nil)
1653+
if hfRR.Code != http.StatusOK {
1654+
t.Fatalf("HF-id status = %d, body=%s", hfRR.Code, hfRR.Body.String())
1655+
}
1656+
1657+
pinnedRR := postChat(t, server, []byte(`{"model":"`+catalogKey+`","messages":[{"role":"user","content":"hello"}],"stream":false}`), http.Header{
1658+
"X-MacProvider-Provider": []string{"p1"},
1659+
})
1660+
if pinnedRR.Code != http.StatusOK {
1661+
t.Fatalf("pinned catalog-key status = %d, body=%s", pinnedRR.Code, pinnedRR.Body.String())
1662+
}
1663+
if pinnedRR.Header().Get("X-MacProvider-Provider") != "p1" {
1664+
t.Fatalf("pinned catalog-key provider header = %q", pinnedRR.Header().Get("X-MacProvider-Provider"))
1665+
}
1666+
}
1667+
16191668
func TestHTTPForwardingStripsReceiptFromProviderWithoutPublishedReceiptKey(t *testing.T) {
16201669
const spoofedReceipt = "spoofed.receipt"
16211670
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

phase4-coordinator/internal/pool/provider.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"sync"
1212
"time"
1313

14+
"github.com/augstar/macprovider-coordinator/internal/billing"
1415
"github.com/augstar/macprovider-coordinator/internal/config"
1516
)
1617

@@ -1447,9 +1448,12 @@ func (r *Registry) recordCanaryResult(providerID, assignedID string, passed bool
14471448
return result
14481449
}
14491450

1450-
// providerServesActiveModel reports whether p's ACTIVE loaded model is modelID,
1451-
// using the same case-folded comparison as buyer routing (`modelIDEqual` →
1452-
// `strings.EqualFold`, buyer/server.go). It deliberately does NOT consult
1451+
// providerServesActiveModel reports whether p's ACTIVE loaded model is modelID
1452+
// under exact case-folded identity. Unlike buyer routing's modelIDEqual
1453+
// (billing.ModelsEquivalent after #900), this stays EqualFold-only: canary
1454+
// sole-provider floor and redundancy telemetry compare provider-to-provider
1455+
// served ModelIDs (HF form), and deliberately treat quantization / catalog
1456+
// aliases as distinct so the floor stays conservative. It does NOT consult
14531457
// `SupportedModels`: a declared-but-cold model is buyer-unroutable (SPEC-031 §9 /
14541458
// SPEC-010 R-3.3.4 — "known but temporarily unavailable", it 503s), so counting
14551459
// it as live capacity would let a peer serving a different model falsely lift the
@@ -2216,7 +2220,15 @@ func (r *Registry) ModelKnown(modelID string) bool {
22162220
// ASCII/Latin common case, ~all realistic model ids).
22172221
// 2. On miss, EqualFold scan the lifetime accumulator before
22182222
// falling through to the live/per-session paths.
2223+
//
2224+
// Issue #900: also accept rate-card / catalog keys that normalize
2225+
// to the same catalog identity as a seen or live HF model id
2226+
// (openai/gpt-oss-20b ↔ mlx-community/gpt-oss-20b-MXFP4-Q8).
22192227
canonical := strings.ToLower(modelID)
2228+
// Hoist query normalization once; fallback scans only normalize
2229+
// stored ids (issue #900 audit: avoid re-allocating on the buyer
2230+
// string inside every EqualFold-miss iteration under RLock).
2231+
normalizedQuery := billing.NormalizeModelKey(modelID)
22202232
r.mu.RLock()
22212233
defer r.mu.RUnlock()
22222234
if _, ok := r.seenModelsLifetime[canonical]; ok {
@@ -2225,9 +2237,9 @@ func (r *Registry) ModelKnown(modelID string) bool {
22252237
// Non-ASCII / case-folding-edge fallback: EqualFold scan of
22262238
// lifetime keys. Bounded by maxSeenModelsLifetime = 4096; only
22272239
// pays the cost on the never-recorded path (i.e., the 404
2228-
// candidate).
2240+
// candidate). Catalog-key equivalence uses the same bound.
22292241
for stored := range r.seenModelsLifetime {
2230-
if strings.EqualFold(stored, modelID) {
2242+
if modelKnownMatch(stored, modelID, normalizedQuery) {
22312243
return true
22322244
}
22332245
}
@@ -2239,7 +2251,7 @@ func (r *Registry) ModelKnown(modelID string) bool {
22392251
//
22402252
// 1. Live providers' current ModelID field.
22412253
for _, p := range r.providers {
2242-
if strings.EqualFold(p.ModelID, modelID) {
2254+
if modelKnownMatch(p.ModelID, modelID, normalizedQuery) {
22432255
return true
22442256
}
22452257
}
@@ -2257,7 +2269,7 @@ func (r *Registry) ModelKnown(modelID string) bool {
22572269
// unconditional guarantee while the declaring provider is live.
22582270
for _, p := range r.providers {
22592271
for _, supported := range p.SupportedModels {
2260-
if strings.EqualFold(supported, modelID) {
2272+
if modelKnownMatch(supported, modelID, normalizedQuery) {
22612273
return true
22622274
}
22632275
}
@@ -2273,14 +2285,21 @@ func (r *Registry) ModelKnown(modelID string) bool {
22732285
return true
22742286
}
22752287
for stored := range set {
2276-
if strings.EqualFold(stored, modelID) {
2288+
if modelKnownMatch(stored, modelID, normalizedQuery) {
22772289
return true
22782290
}
22792291
}
22802292
}
22812293
return false
22822294
}
22832295

2296+
func modelKnownMatch(stored, modelID, normalizedQuery string) bool {
2297+
if strings.EqualFold(stored, modelID) {
2298+
return true
2299+
}
2300+
return billing.MatchesNormalizedKey(stored, normalizedQuery)
2301+
}
2302+
22842303
type StateUpdate struct {
22852304
State State
22862305
SlotsFree *int

phase4-coordinator/internal/pool/provider_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1856,6 +1856,51 @@ func TestModelKnownPersistsInLifetimeAccumulator(t *testing.T) {
18561856
}
18571857
}
18581858

1859+
// TestModelKnownAcceptsCatalogKeyAlias pins issue #900: a buyer
1860+
// catalog / rate-card key must be ModelKnown when a provider has
1861+
// advertised the equivalent served HuggingFace id. Without this, the
1862+
// buyer port 404s on openai/gpt-oss-20b while the HF id routes fine.
1863+
func TestModelKnownAcceptsCatalogKeyAlias(t *testing.T) {
1864+
registry := NewRegistry(nil)
1865+
start := time.Unix(1716768000, 0).UTC()
1866+
const hfID = "mlx-community/gpt-oss-20b-MXFP4-Q8"
1867+
const catalogKey = "openai/gpt-oss-20b"
1868+
1869+
registry.Register(&Provider{
1870+
ProviderID: "p1",
1871+
AssignedID: "s1",
1872+
ModelID: hfID,
1873+
State: StateReady,
1874+
SlotsFree: 1,
1875+
SlotsTotal: 1,
1876+
LastHeartbeatAt: start,
1877+
LastActivityAt: start,
1878+
MaxConcurrency: 1,
1879+
MaxContextTokens: 20000,
1880+
}, nil)
1881+
1882+
if !registry.ModelKnown(hfID) {
1883+
t.Fatalf("ModelKnown(%q) = false for served HF id", hfID)
1884+
}
1885+
if !registry.ModelKnown(catalogKey) {
1886+
t.Fatalf("ModelKnown(%q) = false; catalog key must alias served HF id (#900)", catalogKey)
1887+
}
1888+
if registry.ModelKnown("openai/gpt-oss-120b") {
1889+
t.Fatal("ModelKnown(openai/gpt-oss-120b) = true; unrelated catalog key must stay unknown")
1890+
}
1891+
if registry.ModelKnown("qwen/gpt-oss-20b") {
1892+
t.Fatal("ModelKnown(qwen/gpt-oss-20b) = true; foreign namespace must not spoof openai catalog key")
1893+
}
1894+
1895+
// Lifetime path: after disconnect, catalog key still known → 503 not 404.
1896+
if !registry.RemoveIfSession("p1", "s1") {
1897+
t.Fatal("RemoveIfSession returned false")
1898+
}
1899+
if !registry.ModelKnown(catalogKey) {
1900+
t.Fatalf("ModelKnown(%q) = false after disconnect; catalog-key lifetime alias regressed", catalogKey)
1901+
}
1902+
}
1903+
18591904
// TestModelKnownUnionsDeclaredSupportedModels pins SPEC-010 v1.5
18601905
// R-3.3.4: the seen-model index is the UNION of a provider's served
18611906
// ModelID and every entry in its SupportedModels, so ModelKnown()

0 commit comments

Comments
 (0)