mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(coderd): preserve gateway model names (#26039)
OpenAI-compatible gateway providers such as OpenRouter require slash-namespaced model IDs to reach the intended upstream model, but native OpenAI routing strips those prefixes. Preserve full model IDs for gateway provider types, reject OpenRouter-like providers configured as native `openai` when a slash model would be stripped, and validate chat model config changes under the provider reference lock while still allowing unrelated edits to existing configs. Split from #26005. > Mux created this PR on behalf of Mike.
This commit is contained in:
+42
-1
@@ -6794,6 +6794,26 @@ func (api *API) listChatModelConfigs(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type chatModelConfigProviderModelError struct {
|
||||
Response codersdk.Response
|
||||
}
|
||||
|
||||
func (e *chatModelConfigProviderModelError) Error() string {
|
||||
return e.Response.Message
|
||||
}
|
||||
|
||||
func validateChatModelConfigProviderModel(aiProvider database.AIProvider, model string) *chatModelConfigProviderModelError {
|
||||
if err := chatd.ValidateAIGatewayProviderModel(aiProvider, model); err != nil {
|
||||
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.",
|
||||
},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
apiKey := httpmw.APIKey(r)
|
||||
@@ -6839,6 +6859,11 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if validationErr := validateChatModelConfigProviderModel(aiProvider, model); validationErr != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, validationErr.Response)
|
||||
return
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
@@ -6906,6 +6931,9 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) {
|
||||
return errChatProviderNotConfigured
|
||||
}
|
||||
insertParams.Provider = string(lockedAIProvider.Type)
|
||||
if err := validateChatModelConfigProviderModel(lockedAIProvider, insertParams.Model); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
insertAsDefault := isDefault
|
||||
if !insertAsDefault {
|
||||
@@ -6945,7 +6973,11 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) {
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
var providerModelErr *chatModelConfigProviderModelError
|
||||
switch {
|
||||
case errors.As(err, &providerModelErr):
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, providerModelErr.Response)
|
||||
return
|
||||
case database.IsUniqueViolation(err):
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
Message: "Chat model config already exists.",
|
||||
@@ -7108,9 +7140,11 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) {
|
||||
ID: existing.ID,
|
||||
}
|
||||
|
||||
// Re-derive the provider type under lock when the model or provider changes.
|
||||
revalidateProviderModel := updateParams.AIProviderID.Valid && (req.AIProviderID != nil || strings.TrimSpace(req.Model) != "")
|
||||
var updated database.ChatModelConfig
|
||||
err = api.Database.InTx(func(tx database.Store) error {
|
||||
if updateParams.AIProviderID.Valid && req.AIProviderID != nil {
|
||||
if revalidateProviderModel {
|
||||
//nolint:gocritic // The route already authorized chat model config updates.
|
||||
aiProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), updateParams.AIProviderID.UUID)
|
||||
if err != nil {
|
||||
@@ -7123,6 +7157,9 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) {
|
||||
return errChatProviderNotConfigured
|
||||
}
|
||||
updateParams.Provider = string(aiProvider.Type)
|
||||
if err := validateChatModelConfigProviderModel(aiProvider, updateParams.Model); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
setAsDefault := updateParams.IsDefault && !existing.IsDefault
|
||||
@@ -7165,7 +7202,11 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) {
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
var providerModelErr *chatModelConfigProviderModelError
|
||||
switch {
|
||||
case errors.As(err, &providerModelErr):
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, providerModelErr.Response)
|
||||
return
|
||||
case database.IsUniqueViolation(err):
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
Message: "Chat model config already exists.",
|
||||
|
||||
@@ -5,9 +5,123 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestValidateChatModelConfigProviderModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
provider database.AIProvider
|
||||
wantErr bool
|
||||
wantDetail string
|
||||
}{
|
||||
{
|
||||
name: "OpenRouterNameWithOpenAITypeAndSlashModel",
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
provider: database.AIProvider{
|
||||
Name: "openrouter",
|
||||
Type: database.AiProviderTypeOpenai,
|
||||
},
|
||||
wantErr: true,
|
||||
wantDetail: "Change the AI provider type to openrouter or openai-compat.",
|
||||
},
|
||||
{
|
||||
name: "OpenRouterNameWithWhitespaceAndCase",
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
provider: database.AIProvider{
|
||||
Name: " OpenRouter ",
|
||||
Type: database.AiProviderTypeOpenai,
|
||||
},
|
||||
wantErr: true,
|
||||
wantDetail: "Change the AI provider type to openrouter or openai-compat.",
|
||||
},
|
||||
{
|
||||
name: "OpenRouterHostWithOpenAITypeAndSlashModel",
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
provider: database.AIProvider{
|
||||
Name: "private-relay",
|
||||
Type: database.AiProviderTypeOpenai,
|
||||
BaseUrl: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
wantErr: true,
|
||||
wantDetail: "Change the AI provider type to openrouter or openai-compat.",
|
||||
},
|
||||
{
|
||||
name: "OpenRouterHostWithPort",
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
provider: database.AIProvider{
|
||||
Name: "private-relay",
|
||||
Type: database.AiProviderTypeOpenai,
|
||||
BaseUrl: "https://openrouter.ai:443/api/v1",
|
||||
},
|
||||
wantErr: true,
|
||||
wantDetail: "Change the AI provider type to openrouter or openai-compat.",
|
||||
},
|
||||
{
|
||||
name: "OpenRouterSubdomainWithOpenAIType",
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
provider: database.AIProvider{
|
||||
Name: "private-relay",
|
||||
Type: database.AiProviderTypeOpenai,
|
||||
BaseUrl: "https://api.openrouter.ai/v1",
|
||||
},
|
||||
wantErr: true,
|
||||
wantDetail: "Change the AI provider type to openrouter or openai-compat.",
|
||||
},
|
||||
{
|
||||
name: "OpenRouterTypeAllowsSlashModel",
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
provider: database.AIProvider{
|
||||
Name: "openrouter",
|
||||
Type: database.AiProviderTypeOpenrouter,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OpenAICompatTypeAllowsSlashModel",
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
provider: database.AIProvider{
|
||||
Name: "openrouter",
|
||||
Type: database.AiProviderTypeOpenaiCompat,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PrivateOpenAIProxyAllowsSlashModel",
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
provider: database.AIProvider{
|
||||
Name: "private-relay",
|
||||
Type: database.AiProviderTypeOpenai,
|
||||
BaseUrl: "https://llm-relay.internal/v1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OpenRouterNameWithPlainModelAllowed",
|
||||
model: "gpt-4.1",
|
||||
provider: database.AIProvider{
|
||||
Name: "openrouter",
|
||||
Type: database.AiProviderTypeOpenai,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := validateChatModelConfigProviderModel(tt.provider, tt.model)
|
||||
if tt.wantErr {
|
||||
require.NotNil(t, got)
|
||||
require.Contains(t, got.Response.Detail, tt.wantDetail)
|
||||
return
|
||||
}
|
||||
require.Nil(t, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteChatStartWorkspaceManualUpdateResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -3716,6 +3716,33 @@ func TestCreateChatModelConfig(t *testing.T) {
|
||||
require.Equal(t, "AI provider is disabled.", sdkErr.Message)
|
||||
})
|
||||
|
||||
t.Run("RejectsOpenRouterMisconfiguredAsOpenAI", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := newChatClient(t)
|
||||
_ = coderdtest.CreateFirstUser(t, client.Client)
|
||||
|
||||
aiProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "openrouter",
|
||||
Enabled: true,
|
||||
BaseURL: "https://openrouter.ai/api/v1",
|
||||
APIKeys: []string{"test-api-key"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
contextLimit := int64(4096)
|
||||
_, err = client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
|
||||
AIProviderID: &aiProvider.ID,
|
||||
Model: "anthropic/claude-opus-4.6",
|
||||
ContextLimit: &contextLimit,
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, "OpenRouter-like provider configured as type openai does not support slash-namespaced models.", sdkErr.Message)
|
||||
require.Contains(t, sdkErr.Detail, "Change the AI provider type to openrouter or openai-compat.")
|
||||
})
|
||||
|
||||
t.Run("ForbiddenForOrganizationMember", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -3795,6 +3822,108 @@ func TestUpdateChatModelConfig(t *testing.T) {
|
||||
require.Equal(t, "gpt-4o-mini-updated", updated.Model)
|
||||
})
|
||||
|
||||
t.Run("RejectsOpenRouterMisconfiguredAsOpenAI", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := newChatClient(t)
|
||||
_ = coderdtest.CreateFirstUser(t, client.Client)
|
||||
|
||||
aiProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "openrouter",
|
||||
Enabled: true,
|
||||
BaseURL: "https://openrouter.ai/api/v1",
|
||||
APIKeys: []string{"test-api-key"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
contextLimit := int64(4096)
|
||||
modelConfig, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
|
||||
AIProviderID: &aiProvider.ID,
|
||||
Model: "gpt-4o-mini",
|
||||
ContextLimit: &contextLimit,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{
|
||||
Model: "anthropic/claude-opus-4.6",
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, "OpenRouter-like provider configured as type openai does not support slash-namespaced models.", sdkErr.Message)
|
||||
require.Contains(t, sdkErr.Detail, "Change the AI provider type to openrouter or openai-compat.")
|
||||
})
|
||||
|
||||
t.Run("AllowsUnrelatedEditOnExistingMisconfiguredOpenAI", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
_ = coderdtest.CreateFirstUser(t, client.Client)
|
||||
|
||||
aiProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "openrouter",
|
||||
Enabled: true,
|
||||
BaseURL: "https://openrouter.ai/api/v1",
|
||||
APIKeys: []string{"test-api-key"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: string(database.AiProviderTypeOpenai),
|
||||
Model: "anthropic/claude-opus-4.6",
|
||||
AIProviderID: uuid.NullUUID{UUID: aiProvider.ID, Valid: true},
|
||||
})
|
||||
|
||||
updated, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{
|
||||
DisplayName: "Existing OpenRouter Config",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Existing OpenRouter Config", updated.DisplayName)
|
||||
require.Equal(t, modelConfig.Model, updated.Model)
|
||||
})
|
||||
|
||||
t.Run("RejectsProviderChangeToMisconfiguredOpenAI", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := newChatClient(t)
|
||||
_ = coderdtest.CreateFirstUser(t, client.Client)
|
||||
|
||||
validProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenrouter,
|
||||
Name: "openrouter-valid",
|
||||
Enabled: true,
|
||||
BaseURL: "https://openrouter.ai/api/v1",
|
||||
APIKeys: []string{"test-api-key"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
misconfiguredProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
|
||||
Type: codersdk.AIProviderTypeOpenAI,
|
||||
Name: "openrouter",
|
||||
Enabled: true,
|
||||
BaseURL: "https://openrouter.ai/api/v1",
|
||||
APIKeys: []string{"test-api-key"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
contextLimit := int64(4096)
|
||||
modelConfig, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
|
||||
AIProviderID: &validProvider.ID,
|
||||
Model: "anthropic/claude-opus-4.6",
|
||||
ContextLimit: &contextLimit,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{
|
||||
AIProviderID: &misconfiguredProvider.ID,
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, "OpenRouter-like provider configured as type openai does not support slash-namespaced models.", sdkErr.Message)
|
||||
require.Contains(t, sdkErr.Detail, "Change the AI provider type to openrouter or openai-compat.")
|
||||
})
|
||||
|
||||
t.Run("DisablePreservesRecordAndHidesItFromNonAdmins", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package chatprovider
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -186,6 +187,30 @@ func (k ProviderAPIKeys) BaseURL(provider string) string {
|
||||
return strings.TrimSpace(k.BaseURLByProvider[normalized])
|
||||
}
|
||||
|
||||
// ProviderBaseURLHostname returns the normalized hostname from a provider base URL.
|
||||
func ProviderBaseURLHostname(baseURL string) string {
|
||||
parsed, ok := parseProviderBaseURL(baseURL)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(parsed.Hostname())
|
||||
}
|
||||
|
||||
func parseProviderBaseURL(baseURL string) (*neturl.URL, bool) {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
if baseURL == "" {
|
||||
return nil, false
|
||||
}
|
||||
parsed, err := neturl.Parse(baseURL)
|
||||
if err == nil && parsed.Hostname() == "" && !strings.Contains(baseURL, "://") {
|
||||
parsed, err = neturl.Parse("https://" + baseURL)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// MergeProviderAPIKeys overlays configured provider keys over fallback keys.
|
||||
func MergeProviderAPIKeys(fallback ProviderAPIKeys, providers []ConfiguredProvider) ProviderAPIKeys {
|
||||
merged := ProviderAPIKeys{
|
||||
|
||||
@@ -29,6 +29,28 @@ import (
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestProviderBaseURLHostname(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
want string
|
||||
}{
|
||||
{name: "URL", baseURL: "https://openrouter.ai/api/v1", want: "openrouter.ai"},
|
||||
{name: "BareHost", baseURL: "openrouter.ai", want: "openrouter.ai"},
|
||||
{name: "HostWithPort", baseURL: "https://openrouter.ai:443/api/v1", want: "openrouter.ai"},
|
||||
{name: "Empty", baseURL: "", want: ""},
|
||||
{name: "Invalid", baseURL: "://", want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tt.want, chatprovider.ProviderBaseURLHostname(tt.baseURL))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUserProviderKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1547,6 +1569,34 @@ func TestResolveModelWithProviderHint(t *testing.T) {
|
||||
wantProvider: fantasyopenaicompat.Name,
|
||||
wantModel: "anthropic/claude-4-5-sonnet",
|
||||
},
|
||||
{
|
||||
name: "OpenRouterHintPreservesOpenRouterModelID",
|
||||
modelName: "anthropic/claude-opus-4.6",
|
||||
providerHint: fantasyopenrouter.Name,
|
||||
wantProvider: fantasyopenrouter.Name,
|
||||
wantModel: "anthropic/claude-opus-4.6",
|
||||
},
|
||||
{
|
||||
name: "OpenAICompatHintPreservesOpenRouterModelID",
|
||||
modelName: "anthropic/claude-opus-4.6",
|
||||
providerHint: fantasyopenaicompat.Name,
|
||||
wantProvider: fantasyopenaicompat.Name,
|
||||
wantModel: "anthropic/claude-opus-4.6",
|
||||
},
|
||||
{
|
||||
name: "OpenAIHintStripsCanonicalPrefix",
|
||||
modelName: "anthropic/claude-opus-4.6",
|
||||
providerHint: fantasyopenai.Name,
|
||||
wantProvider: fantasyanthropic.Name,
|
||||
wantModel: "claude-opus-4.6",
|
||||
},
|
||||
{
|
||||
name: "OpenAIHintPreservesUnknownSlashNamespace",
|
||||
modelName: "meta-llama/llama-3-70b",
|
||||
providerHint: fantasyopenai.Name,
|
||||
wantProvider: fantasyopenai.Name,
|
||||
wantModel: "meta-llama/llama-3-70b",
|
||||
},
|
||||
{
|
||||
name: "AnthropicHintStripsCanonicalPrefix",
|
||||
modelName: "anthropic/claude-4-5-sonnet",
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -150,8 +149,8 @@ func rewriteOpenAICompatSingleToolChoice(payload map[string]any) bool {
|
||||
// endpoints and Coder AI Bridge Gemini routes. Other gateways, such as Vercel,
|
||||
// keep their own provider-specific compatibility behavior.
|
||||
func shouldAddGoogleOpenAICompatThoughtSignatures(baseURL string, modelID string) bool {
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
parsed, ok := parseProviderBaseURL(baseURL)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
|
||||
@@ -87,6 +87,32 @@ 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.
|
||||
func ValidateAIGatewayProviderModel(provider database.AIProvider, model string) error {
|
||||
if provider.Type != database.AiProviderTypeOpenai {
|
||||
return nil
|
||||
}
|
||||
if !isSlashNamespacedAIGatewayModel(model) || !isOpenRouterLikeAIGatewayProvider(provider) {
|
||||
return nil
|
||||
}
|
||||
return xerrors.New("OpenRouter-like provider configured as type openai does not support slash-namespaced models")
|
||||
}
|
||||
|
||||
func isSlashNamespacedAIGatewayModel(model string) bool {
|
||||
prefix, suffix, ok := strings.Cut(strings.TrimSpace(model), "/")
|
||||
return ok && strings.TrimSpace(prefix) != "" && strings.TrimSpace(suffix) != ""
|
||||
}
|
||||
|
||||
func isOpenRouterLikeAIGatewayProvider(provider database.AIProvider) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(provider.Name), "openrouter") {
|
||||
return true
|
||||
}
|
||||
host := chatprovider.ProviderBaseURLHostname(provider.BaseUrl)
|
||||
return host == "openrouter.ai" || strings.HasSuffix(host, ".openrouter.ai")
|
||||
}
|
||||
|
||||
func (p *Server) newAIGatewayModel(
|
||||
_ context.Context,
|
||||
req modelClientRequest,
|
||||
@@ -110,6 +136,17 @@ func (p *Server) newAIGatewayModel(
|
||||
)
|
||||
}
|
||||
|
||||
if err := ValidateAIGatewayProviderModel(route.Provider, req.ModelName); err != nil {
|
||||
return nil, chaterror.WithClassification(
|
||||
err,
|
||||
chaterror.ClassifiedError{
|
||||
Kind: codersdk.ChatErrorKindConfig,
|
||||
Retryable: false,
|
||||
Detail: "Ask an administrator to change the AI provider type to openrouter or openai-compat.",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
factoryPtr := p.aibridgeTransportFactory
|
||||
if factoryPtr == nil {
|
||||
return nil, xerrors.New("AI Gateway transport factory is not configured")
|
||||
|
||||
@@ -2,6 +2,7 @@ package chatd
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -641,6 +642,29 @@ func TestAIBridgeRoutingFailClosed(t *testing.T) {
|
||||
require.False(t, classified.Retryable)
|
||||
})
|
||||
|
||||
t.Run("OpenRouterMisconfiguredAsOpenAI", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
factory := &aibridgeTestFactory{rt: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
t.Fatal("transport must not be used for invalid provider config")
|
||||
return nil, xerrors.New("unreachable")
|
||||
})}
|
||||
server := &Server{
|
||||
aiGatewayRoutingEnabled: true,
|
||||
aibridgeTransportFactory: aibridgeTestFactoryPointer(factory),
|
||||
}
|
||||
provider := aibridgeTestAIProvider(providerID, "openrouter", database.AiProviderTypeOpenai)
|
||||
_, err := server.newModel(
|
||||
t.Context(),
|
||||
aibridgeTestRequest(chat, "anthropic/claude-opus-4.6"),
|
||||
aibridgeTestRoute(provider),
|
||||
modelBuildOptions{ActiveAPIKeyID: uuid.NewString()},
|
||||
)
|
||||
require.ErrorContains(t, err, "does not support slash-namespaced models")
|
||||
classified := chaterror.Classify(err)
|
||||
require.Equal(t, codersdk.ChatErrorKindConfig, classified.Kind)
|
||||
require.False(t, classified.Retryable)
|
||||
})
|
||||
|
||||
t.Run("StaticModel", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := &Server{aiGatewayRoutingEnabled: true}
|
||||
@@ -649,6 +673,112 @@ func TestAIBridgeRoutingFailClosed(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAIBridgeGatewayProviderTypesPreserveSlashModelID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const modelName = "anthropic/claude-opus-4.6"
|
||||
tests := []struct {
|
||||
name string
|
||||
providerName string
|
||||
providerType database.AIProviderType
|
||||
}{
|
||||
{
|
||||
name: "OpenRouter",
|
||||
providerName: "openrouter",
|
||||
providerType: database.AiProviderTypeOpenrouter,
|
||||
},
|
||||
{
|
||||
name: "OpenAICompat",
|
||||
providerName: "openai-compatible-relay",
|
||||
providerType: database.AiProviderTypeOpenaiCompat,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type seenRequest struct {
|
||||
model string
|
||||
path string
|
||||
}
|
||||
seen := make(chan seenRequest, 1)
|
||||
factory := &aibridgeTestFactory{rt: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
require.NoError(t, err)
|
||||
var payload struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &payload))
|
||||
seen <- seenRequest{model: payload.Model, path: req.URL.Path}
|
||||
|
||||
var responsePayload map[string]any
|
||||
if strings.Contains(req.URL.Path, "/responses") {
|
||||
responsePayload = map[string]any{
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"created_at": 0,
|
||||
"status": "completed",
|
||||
"model": modelName,
|
||||
"output": []map[string]any{{
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": []map[string]any{{"type": "output_text", "text": "hello"}},
|
||||
}},
|
||||
"usage": map[string]any{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
||||
}
|
||||
} else {
|
||||
responsePayload = map[string]any{
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": modelName,
|
||||
"choices": []map[string]any{{
|
||||
"index": 0,
|
||||
"message": map[string]any{"role": "assistant", "content": "hello"},
|
||||
"finish_reason": "stop",
|
||||
}},
|
||||
"usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
}
|
||||
}
|
||||
responseBody, err := json.Marshal(responsePayload)
|
||||
require.NoError(t, err)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(string(responseBody))),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New()}
|
||||
server := &Server{
|
||||
aiGatewayRoutingEnabled: true,
|
||||
aibridgeTransportFactory: aibridgeTestFactoryPointer(factory),
|
||||
}
|
||||
|
||||
model, err := server.newModel(
|
||||
t.Context(),
|
||||
aibridgeTestRequest(chat, modelName),
|
||||
aibridgeTestRoute(aibridgeTestAIProvider(uuid.New(), tt.providerName, tt.providerType)),
|
||||
modelBuildOptions{ActiveAPIKeyID: uuid.NewString()},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
_, err = model.Generate(t.Context(), fantasy.Call{Prompt: []fantasy.Message{{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
||||
}}})
|
||||
require.NoError(t, err)
|
||||
|
||||
got := <-seen
|
||||
require.NotEmpty(t, got.path)
|
||||
require.Equal(t, modelName, got.model)
|
||||
require.Equal(t, tt.providerName, factory.providerName)
|
||||
require.Equal(t, aibridge.SourceAgents, factory.source)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectModelBuildDoesNotRequireActiveAPIKeyID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user