mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user