Skip to content

Commit 6dd2b22

Browse files
fix(core): preserve slash model ids for explicit providers
1 parent a617ba1 commit 6dd2b22

6 files changed

Lines changed: 107 additions & 32 deletions

File tree

internal/aliases/service_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,45 @@ func TestServiceSupportsQualifiedAliasNames(t *testing.T) {
196196
}
197197
}
198198

199+
func TestServiceResolveAliasWithExplicitProviderAndSlashModel(t *testing.T) {
200+
catalog := newTestCatalog()
201+
catalog.add(
202+
"groq/openai/gpt-oss-120b",
203+
"groq",
204+
core.Model{ID: "openai/gpt-oss-120b", Object: "model", OwnedBy: "groq"},
205+
)
206+
207+
service, err := NewService(newMemoryStore(Alias{
208+
Name: "smart",
209+
TargetModel: "openai/gpt-oss-120b",
210+
TargetProvider: "groq",
211+
Enabled: true,
212+
}), catalog)
213+
if err != nil {
214+
t.Fatalf("NewService() error = %v", err)
215+
}
216+
if err := service.Refresh(context.Background()); err != nil {
217+
t.Fatalf("Refresh() error = %v", err)
218+
}
219+
220+
resolution, ok, err := service.Resolve("smart", "")
221+
if err != nil {
222+
t.Fatalf("Resolve() error = %v", err)
223+
}
224+
if !ok {
225+
t.Fatal("Resolve() ok = false, want true")
226+
}
227+
if got := resolution.Resolved.Model; got != "openai/gpt-oss-120b" {
228+
t.Fatalf("resolved model = %q, want openai/gpt-oss-120b", got)
229+
}
230+
if got := resolution.Resolved.Provider; got != "groq" {
231+
t.Fatalf("resolved provider = %q, want groq", got)
232+
}
233+
if got := resolution.Resolved.QualifiedModel(); got != "groq/openai/gpt-oss-120b" {
234+
t.Fatalf("resolved selector = %q, want groq/openai/gpt-oss-120b", got)
235+
}
236+
}
237+
199238
func TestServiceUpsertRejectsQualifiedAliasChainsAndSelfTargets(t *testing.T) {
200239
catalog := newTestCatalog()
201240
catalog.add("gpt-4o", "openai", core.Model{ID: "gpt-4o", Object: "model"})

internal/core/model_selector.go

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ func (s ModelSelector) QualifiedModel() string {
2626
// - model only: "gpt-4o"
2727
// - model with prefix: "openai/gpt-4o"
2828
// - explicit provider field: provider="openai", model="gpt-4o"
29+
// - explicit provider with raw slash model: provider="groq", model="openai/gpt-oss-120b"
2930
//
30-
// If provider is present in both places, values must match.
31+
// When provider is explicit, it is authoritative. A matching leading
32+
// "provider/" prefix on the model is stripped once as redundant qualification.
3133
func ParseModelSelector(model, provider string) (ModelSelector, error) {
3234
model = strings.TrimSpace(model)
3335
provider = strings.TrimSpace(provider)
@@ -36,17 +38,13 @@ func ParseModelSelector(model, provider string) (ModelSelector, error) {
3638
return ModelSelector{}, fmt.Errorf("model is required")
3739
}
3840

39-
parts := strings.SplitN(model, "/", 2)
40-
if len(parts) == 2 {
41-
prefix := strings.TrimSpace(parts[0])
42-
rest := strings.TrimSpace(parts[1])
43-
if prefix != "" && rest != "" {
44-
if provider != "" && provider != prefix {
45-
return ModelSelector{}, fmt.Errorf("provider field %q conflicts with model prefix %q", provider, prefix)
46-
}
47-
provider = prefix
41+
if provider != "" {
42+
if prefix, rest, ok := splitQualifiedModel(model); ok && prefix == provider {
4843
model = rest
4944
}
45+
} else if prefix, rest, ok := splitQualifiedModel(model); ok {
46+
provider = prefix
47+
model = rest
5048
}
5149

5250
if model == "" {
@@ -58,3 +56,16 @@ func ParseModelSelector(model, provider string) (ModelSelector, error) {
5856
Provider: provider,
5957
}, nil
6058
}
59+
60+
func splitQualifiedModel(model string) (prefix, rest string, ok bool) {
61+
parts := strings.SplitN(model, "/", 2)
62+
if len(parts) != 2 {
63+
return "", "", false
64+
}
65+
prefix = strings.TrimSpace(parts[0])
66+
rest = strings.TrimSpace(parts[1])
67+
if prefix == "" || rest == "" {
68+
return "", "", false
69+
}
70+
return prefix, rest, true
71+
}

internal/core/model_selector_test.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,20 @@ func TestParseModelSelector(t *testing.T) {
3535
wantQualified: "openai/gpt-4o",
3636
},
3737
{
38-
name: "provider conflict",
39-
model: "openai/gpt-4o",
40-
provider: "anthropic",
41-
wantErr: true,
38+
name: "matching provider prefix normalizes once",
39+
model: "openai/gpt-4o",
40+
provider: "openai",
41+
wantModel: "gpt-4o",
42+
wantProvider: "openai",
43+
wantQualified: "openai/gpt-4o",
44+
},
45+
{
46+
name: "explicit provider keeps slash model raw",
47+
model: "openai/gpt-oss-120b",
48+
provider: "groq",
49+
wantModel: "openai/gpt-oss-120b",
50+
wantProvider: "groq",
51+
wantQualified: "groq/openai/gpt-oss-120b",
4252
},
4353
{
4454
name: "missing model",

internal/core/request_model_resolution_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ func TestRequestModelResolutionRequestedQualifiedModel(t *testing.T) {
3131
},
3232
want: "openai/gpt-4o",
3333
},
34+
{
35+
name: "explicit provider preserves raw slash model",
36+
in: &RequestModelResolution{
37+
RequestedModel: "openai/gpt-oss-120b",
38+
RequestedProvider: "groq",
39+
},
40+
want: "groq/openai/gpt-oss-120b",
41+
},
3442
}
3543

3644
for _, tt := range tests {

internal/providers/router_test.go

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -482,25 +482,30 @@ func TestRouterChatCompletion_PrefixedModelSelector(t *testing.T) {
482482
}
483483
}
484484

485-
func TestRouterChatCompletion_ProviderConflict(t *testing.T) {
485+
func TestRouterChatCompletion_ExplicitProviderKeepsSlashModelRaw(t *testing.T) {
486+
groqResp := &core.ChatResponse{ID: "groq", Model: "openai/gpt-oss-120b"}
487+
groq := &mockProvider{name: "groq", chatResponse: groqResp}
488+
486489
lookup := newMockLookup()
487-
lookup.addModel("openai/gpt-4o", &mockProvider{}, "openai")
490+
lookup.addModel("groq/openai/gpt-oss-120b", groq, "groq")
488491

489492
router, _ := NewRouter(lookup)
490493

491-
_, err := router.ChatCompletion(context.Background(), &core.ChatRequest{
492-
Model: "openai/gpt-4o",
493-
Provider: "anthropic",
494+
resp, err := router.ChatCompletion(context.Background(), &core.ChatRequest{
495+
Model: "openai/gpt-oss-120b",
496+
Provider: "groq",
494497
})
495-
if err == nil {
496-
t.Fatal("expected provider conflict error")
498+
if err != nil {
499+
t.Fatalf("unexpected error: %v", err)
497500
}
498-
var gwErr *core.GatewayError
499-
if !errors.As(err, &gwErr) {
500-
t.Fatalf("expected GatewayError, got %T: %v", err, err)
501+
if resp.ID != "groq" {
502+
t.Fatalf("expected groq provider response, got %q", resp.ID)
503+
}
504+
if groq.lastChatReq == nil || groq.lastChatReq.Model != "openai/gpt-oss-120b" {
505+
t.Fatalf("expected upstream model to keep raw slash ID, got %#v", groq.lastChatReq)
501506
}
502-
if gwErr.HTTPStatusCode() != http.StatusBadRequest {
503-
t.Fatalf("expected 400 status, got %d", gwErr.HTTPStatusCode())
507+
if groq.lastChatReq.Provider != "" {
508+
t.Fatalf("expected provider field to be stripped upstream, got %q", groq.lastChatReq.Provider)
504509
}
505510
}
506511

internal/server/model_validation_test.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ func (p *modelCountingValidationProvider) ModelCount() int {
4444
}
4545

4646
func TestModelValidation(t *testing.T) {
47-
provider := &mockProvider{supportedModels: []string{"gpt-4o-mini", "text-embedding-3-small"}}
47+
provider := &mockProvider{
48+
supportedModels: []string{"gpt-4o-mini", "text-embedding-3-small", "openai/gpt-oss-120b"},
49+
providerTypes: map[string]string{"groq/openai/gpt-oss-120b": "groq"},
50+
}
4851

4952
tests := []struct {
5053
name string
@@ -139,13 +142,12 @@ func TestModelValidation(t *testing.T) {
139142
handlerCalled: false,
140143
},
141144
{
142-
name: "provider field conflict returns 400",
145+
name: "provider field keeps slash model raw",
143146
method: http.MethodPost,
144147
path: "/v1/chat/completions",
145-
body: `{"provider":"anthropic","model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}`,
146-
expectedStatus: http.StatusBadRequest,
147-
expectedBody: "conflicts",
148-
handlerCalled: false,
148+
body: `{"provider":"groq","model":"openai/gpt-oss-120b","messages":[{"role":"user","content":"hi"}]}`,
149+
expectedStatus: http.StatusOK,
150+
handlerCalled: true,
149151
},
150152
{
151153
name: "non-model path skips validation",

0 commit comments

Comments
 (0)