diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index 030d4f82b1..768aa5774e 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -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 "/" 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 + // "/" 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 } diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index 234d50857b..0c2cebfbad 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -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) + }) + } +}