From 29d573f4d02dca03e1d143541cff214dd7d285a8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:30:57 +0200 Subject: [PATCH] fix(coderd): send assigned chat model IDs verbatim (#28144) Fixes #27361 (CODAGT-832). ## Problem When an Agents model was configured under a non-gateway provider type (e.g. Anthropic or OpenAI) with a model ID whose first `/`- or `:`-segment matched a built-in provider name (`anthropic`, `azure`, `bedrock`, `google`, `openai`, `openai-compat`, `openrouter`, `vercel`), `chatprovider.ResolveModelWithProviderHint` parsed it as a canonical `provider/model` reference: the prefix was stripped and the request rerouted to the embedded provider type, overriding the provider the admin explicitly assigned. LLM gateways (e.g. LiteLLM) that namespace their catalogs as `bedrock/...` or `anthropic/...` behind an Anthropic- or OpenAI-type provider failed with an opaque upstream "Model not found", and escaping was impossible (`bedrock/bedrock/...` still rerouted). ## Fix A valid provider hint is now authoritative: `ResolveModelWithProviderHint` returns the assigned provider and the verbatim model ID whenever a hint is present. Canonical `provider/model` and `provider:model` parsing applies only to hint-less resolution paths. Every production call site derives the hint from the model config's explicitly assigned AI provider, so the assignment always wins. The save-time guard rejecting slash-namespaced models on OpenRouter-like providers typed as `openai` (provider named `openrouter` or hosted at `openrouter.ai`) is kept: that combination remains a misconfiguration whose correct fix is the `openrouter` provider type, and rejecting it early beats a confusing upstream error. Its wording no longer claims prefix stripping happens. ## Back-compat note A pre-existing config that relied on stripping (e.g. model `anthropic/claude-x` assigned to an Anthropic-type provider pointing at the real Anthropic API) now sends the prefixed ID verbatim and will get a clear upstream model-not-found error; the admin fixes it by editing the model ID. Nothing in the product ever suggested the canonical form for assigned models. ## Validation - Unit: `TestResolveModelWithProviderHint` updated (hints preserve `bedrock/...`, `anthropic/...`, `provider:...` verbatim; hint-less canonical parsing unchanged), red-green verified against the old ordering. Gateway and openai-type provider routing tests assert verbatim pass-through end to end. - Full `./coderd/x/chatd/...` suites plus `TestCreateChatModelConfig`, `TestUpdateChatModelConfig`, and `TestValidateChatModelConfigProviderModel` pass. - Remote dogfood UAT on real models (PASS): an openai-type provider pointed at a Vercel AI Gateway mount returned a real completion for `anthropic/claude-haiku-4.5`, with trace logs confirming `provider=openai model=anthropic/claude-haiku-4.5` (verbatim, not rerouted); gateway-type (`openai-compat`) routing with `deepseek/deepseek-v4-pro-0813` and the model catalog/picker regressions pass. > Mux acted on Mike's behalf to create this PR. (cherry picked from commit 93c6faf1de78b9cbe930295d61abbffd8db6971c) --- coderd/exp_chats.go | 2 +- coderd/x/chatd/chatprovider/chatprovider.go | 30 ++------------- .../x/chatd/chatprovider/chatprovider_test.go | 38 ++++++++++++++++--- coderd/x/chatd/model_routing_aibridge.go | 5 +-- 4 files changed, 40 insertions(+), 35 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 40d1d192233..f3bfa3d172e 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -6853,7 +6853,7 @@ func validateChatModelConfigProviderModel(aiProvider database.AIProvider, model return &chatModelConfigProviderModelError{ Response: codersdk.Response{ Message: "OpenRouter-like provider configured as type openai does not support slash-namespaced models.", - Detail: "Change the AI provider type to openrouter or openai-compat. The openai type strips the vendor prefix from slash-namespaced model IDs, routing to the wrong upstream provider.", + Detail: "Change the AI provider type to openrouter or openai-compat. Slash-namespaced model IDs on OpenRouter-like gateways require one of those provider types.", }, } } diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index 4f116ef1b75..7318f52ed45 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -687,21 +687,6 @@ 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)) { @@ -732,23 +717,16 @@ 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 + // A valid provider hint is authoritative, so preserve the model ID + // instead of interpreting its namespace as a different provider. + if provider := NormalizeProvider(providerHint); provider != "" { + return provider, modelName, nil } if provider, modelID, ok := parseCanonicalModelRef(modelName); ok { return provider, modelID, nil } - if provider := NormalizeProvider(providerHint); provider != "" { - return provider, modelName, nil - } - normalized := strings.ToLower(modelName) switch normalized { case "claude-opus-4-6": diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index 8a8904c8ef4..9877ef4c4e3 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -1659,11 +1659,11 @@ func TestResolveModelWithProviderHint(t *testing.T) { wantModel: "anthropic/claude-opus-4.6", }, { - name: "OpenAIHintStripsCanonicalPrefix", + name: "OpenAIHintPreservesCanonicalPrefix", modelName: "anthropic/claude-opus-4.6", providerHint: fantasyopenai.Name, - wantProvider: fantasyanthropic.Name, - wantModel: "claude-opus-4.6", + wantProvider: fantasyopenai.Name, + wantModel: "anthropic/claude-opus-4.6", }, { name: "OpenAIHintPreservesUnknownSlashNamespace", @@ -1673,11 +1673,39 @@ func TestResolveModelWithProviderHint(t *testing.T) { wantModel: "meta-llama/llama-3-70b", }, { - name: "AnthropicHintStripsCanonicalPrefix", + name: "AnthropicHintPreservesCanonicalPrefix", modelName: "anthropic/claude-4-5-sonnet", providerHint: fantasyanthropic.Name, wantProvider: fantasyanthropic.Name, - wantModel: "claude-4-5-sonnet", + wantModel: "anthropic/claude-4-5-sonnet", + }, + { + name: "AnthropicHintPreservesBedrockNamespace", + modelName: "bedrock/claude-opus-4-8", + providerHint: fantasyanthropic.Name, + wantProvider: fantasyanthropic.Name, + wantModel: "bedrock/claude-opus-4-8", + }, + { + name: "AnthropicHintPreservesDoubleBedrockNamespace", + modelName: "bedrock/bedrock/claude-opus-4-8", + providerHint: fantasyanthropic.Name, + wantProvider: fantasyanthropic.Name, + wantModel: "bedrock/bedrock/claude-opus-4-8", + }, + { + name: "AnthropicHintPreservesColonCanonicalRef", + modelName: "openai:gpt-5.2", + providerHint: fantasyanthropic.Name, + wantProvider: fantasyanthropic.Name, + wantModel: "openai:gpt-5.2", + }, + { + name: "BedrockHintWithBareModel", + modelName: "claude-opus-4-6", + providerHint: fantasybedrock.Name, + wantProvider: fantasybedrock.Name, + wantModel: "claude-opus-4-6", }, { name: "NoHintUsesCanonicalRef", diff --git a/coderd/x/chatd/model_routing_aibridge.go b/coderd/x/chatd/model_routing_aibridge.go index 832cd31a6e1..7fe9320c425 100644 --- a/coderd/x/chatd/model_routing_aibridge.go +++ b/coderd/x/chatd/model_routing_aibridge.go @@ -87,9 +87,8 @@ func (t *aiGatewayRoundTripper) RoundTrip(req *http.Request) (*http.Response, er return t.base.RoundTrip(cloned) } -// ValidateAIGatewayProviderModel rejects slash-namespaced models on -// OpenRouter-like providers typed as openai, where the provider type -// strips the vendor prefix. +// ValidateAIGatewayProviderModel rejects slash-namespaced models when an +// OpenRouter-like gateway is configured with the OpenAI provider type. func ValidateAIGatewayProviderModel(provider database.AIProvider, model string) error { if provider.Type != database.AIProviderTypeOpenai { return nil