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.
This commit is contained in:
Michael Suchacz
2026-08-13 21:30:57 +02:00
committed by GitHub
parent d5bb35a49a
commit 93c6faf1de
4 changed files with 40 additions and 35 deletions
+4 -26
View File
@@ -683,21 +683,6 @@ 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)) {
@@ -728,23 +713,16 @@ 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
// 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":
@@ -1729,11 +1729,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",
@@ -1743,11 +1743,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",
+2 -3
View File
@@ -84,9 +84,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