From 502c5acca88d8f7b55f91c16a7fd6531579e8faa Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:33:00 +0200 Subject: [PATCH] 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. --- coderd/exp_chats.go | 43 +++++- coderd/exp_chats_internal_test.go | 114 +++++++++++++++ coderd/exp_chats_test.go | 129 +++++++++++++++++ coderd/x/chatd/chatprovider/chatprovider.go | 25 ++++ .../x/chatd/chatprovider/chatprovider_test.go | 50 +++++++ .../chatprovider/openai_compat_patches.go | 5 +- coderd/x/chatd/model_routing_aibridge.go | 37 +++++ coderd/x/chatd/model_routing_internal_test.go | 130 ++++++++++++++++++ 8 files changed, 529 insertions(+), 4 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index c8ed16c740..7b15178142 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -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.", diff --git a/coderd/exp_chats_internal_test.go b/coderd/exp_chats_internal_test.go index 17c93182e7..bfa4dc6242 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -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() diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index a676baa7f5..c55d58c269 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -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() diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index fec0840b08..ac817e0940 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -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{ diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index 1d9b43c4ff..0e851d3f89 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -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", diff --git a/coderd/x/chatd/chatprovider/openai_compat_patches.go b/coderd/x/chatd/chatprovider/openai_compat_patches.go index beac165bb5..26a1f80631 100644 --- a/coderd/x/chatd/chatprovider/openai_compat_patches.go +++ b/coderd/x/chatd/chatprovider/openai_compat_patches.go @@ -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()) diff --git a/coderd/x/chatd/model_routing_aibridge.go b/coderd/x/chatd/model_routing_aibridge.go index 07e8fd66b0..a732da1a95 100644 --- a/coderd/x/chatd/model_routing_aibridge.go +++ b/coderd/x/chatd/model_routing_aibridge.go @@ -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") diff --git a/coderd/x/chatd/model_routing_internal_test.go b/coderd/x/chatd/model_routing_internal_test.go index 70e0797883..76ede361de 100644 --- a/coderd/x/chatd/model_routing_internal_test.go +++ b/coderd/x/chatd/model_routing_internal_test.go @@ -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()