fix(coderd/x/chatd/chatprovider): keep gateway model prefix in ResolveModelWithProviderHint (#25725)

For `vercel`, `openrouter`, and `openai-compat`, the
`<provider>/<model>` slash is part of the upstream model ID rather than
a hint. `ResolveModelWithProviderHint` was running
`parseCanonicalModelRef` before honoring `providerHint`, so a config
like `(provider=vercel, model=anthropic/claude-4-5-sonnet)` resolved to
`provider=anthropic, model=claude-4-5-sonnet` and the prefix-less model
name was forwarded to Vercel, which returned `Model 'claude-4-5-sonnet'
not found`.

Honor an explicit gateway provider hint before attempting canonical-ref
parsing. Non-gateway hints (anthropic, openai, etc.) keep the existing
canonical-ref-first behavior so `anthropic/claude-...` still has its
prefix stripped when routed directly to Anthropic.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Danny Kopping
2026-05-27 13:13:39 +02:00
committed by GitHub
parent ae492495ee
commit 10f37db35d
2 changed files with 95 additions and 0 deletions
@@ -573,6 +573,21 @@ func orderProviders(providerSet map[string]struct{}) []string {
return ordered
}
// isGatewayProvider reports whether the provider routes requests to
// multiple upstream model providers using a "<provider>/<model>" model
// identifier, where the slash is part of the upstream model ID rather
// than a hint.
func isGatewayProvider(provider string) bool {
switch provider {
case fantasyvercel.Name,
fantasyopenrouter.Name,
fantasyopenaicompat.Name:
return true
default:
return false
}
}
// NormalizeProvider canonicalizes a provider name.
func NormalizeProvider(provider string) string {
switch strings.ToLower(strings.TrimSpace(provider)) {
@@ -603,6 +618,15 @@ func ResolveModelWithProviderHint(modelName, providerHint string) (provider stri
return "", "", xerrors.New("model is required")
}
// Gateway providers (vercel, openrouter, openai-compat) treat the
// "<provider>/<model>" slash as part of the upstream model ID, so
// parseCanonicalModelRef would incorrectly strip the prefix and
// route to the embedded provider name instead. Honor an explicit
// gateway hint before attempting canonical-ref parsing.
if normalized := NormalizeProvider(providerHint); normalized != "" && isGatewayProvider(normalized) {
return normalized, modelName, nil
}
if provider, modelID, ok := parseCanonicalModelRef(modelName); ok {
return provider, modelID, nil
}
@@ -12,6 +12,7 @@ import (
fantasyanthropic "charm.land/fantasy/providers/anthropic"
fantasybedrock "charm.land/fantasy/providers/bedrock"
fantasyopenai "charm.land/fantasy/providers/openai"
fantasyopenaicompat "charm.land/fantasy/providers/openaicompat"
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
fantasyvercel "charm.land/fantasy/providers/vercel"
"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream"
@@ -1439,3 +1440,73 @@ func TestMergeMissingProviderOptions_OpenRouterNested(t *testing.T) {
require.Equal(t, []string{"int8"}, options.OpenRouter.Provider.Quantizations)
require.Equal(t, "latency", *options.OpenRouter.Provider.Sort)
}
func TestResolveModelWithProviderHint(t *testing.T) {
t.Parallel()
tests := []struct {
name string
modelName string
providerHint string
wantProvider string
wantModel string
wantErr bool
}{
{
name: "VercelHintPreservesPrefixedModelID",
modelName: "anthropic/claude-4-5-sonnet",
providerHint: fantasyvercel.Name,
wantProvider: fantasyvercel.Name,
wantModel: "anthropic/claude-4-5-sonnet",
},
{
name: "OpenRouterHintPreservesPrefixedModelID",
modelName: "anthropic/claude-3.5-haiku",
providerHint: fantasyopenrouter.Name,
wantProvider: fantasyopenrouter.Name,
wantModel: "anthropic/claude-3.5-haiku",
},
{
name: "OpenAICompatHintPreservesPrefixedModelID",
modelName: "anthropic/claude-4-5-sonnet",
providerHint: fantasyopenaicompat.Name,
wantProvider: fantasyopenaicompat.Name,
wantModel: "anthropic/claude-4-5-sonnet",
},
{
name: "AnthropicHintStripsCanonicalPrefix",
modelName: "anthropic/claude-4-5-sonnet",
providerHint: fantasyanthropic.Name,
wantProvider: fantasyanthropic.Name,
wantModel: "claude-4-5-sonnet",
},
{
name: "NoHintUsesCanonicalRef",
modelName: "anthropic/claude-4-5-sonnet",
providerHint: "",
wantProvider: fantasyanthropic.Name,
wantModel: "claude-4-5-sonnet",
},
{
name: "VercelHintWithoutSlashPasses",
modelName: "claude-4-5-sonnet",
providerHint: fantasyvercel.Name,
wantProvider: fantasyvercel.Name,
wantModel: "claude-4-5-sonnet",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
provider, model, err := chatprovider.ResolveModelWithProviderHint(tt.modelName, tt.providerHint)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantProvider, provider)
require.Equal(t, tt.wantModel, model)
})
}
}