From 1b7f3a067fd17824cdede55e8199ff4f8575248c Mon Sep 17 00:00:00 2001 From: Pratham-Mishra04 Date: Mon, 16 Mar 2026 22:54:22 +0530 Subject: [PATCH] feat: standardize empty array conventions in bifrost --- core/bifrost.go | 12 +- core/changelog.md | 1 + core/go.mod | 1 - core/providers/mistral/mistral.go | 2 +- core/providers/mistral/models.go | 6 +- core/schemas/transcriptions.go | 23 +- docs/features/governance/routing.mdx | 22 +- examples/configs/partial/config.json | 12 +- examples/configs/withconfigstore/config.json | 16 +- examples/configs/withlogstore/config.json | 3 +- .../config.json | 11 +- .../configs/withprompushgateway/config.json | 36 ++- examples/configs/withvirtualkeys/config.json | 54 +++-- framework/changelog.md | 1 + framework/configstore/migrations.go | 105 +++++++++ framework/configstore/tables/key.go | 16 +- framework/configstore/tables/virtualkey.go | 24 +- framework/modelcatalog/main.go | 20 +- helm-charts/bifrost/values.schema.json | 4 +- plugins/governance/main.go | 4 +- plugins/governance/resolver.go | 3 +- plugins/governance/resolver_test.go | 14 +- .../bifrost-http/handlers/governance.go | 4 +- transports/bifrost-http/handlers/providers.go | 21 +- transports/bifrost-http/lib/config.go | 46 ---- transports/bifrost-http/server/server.go | 10 + transports/changelog.md | 1 + transports/config.schema.json | 209 +----------------- .../fragments/apiKeysFormFragment.tsx | 28 ++- .../providers/views/providerKeyForm.tsx | 2 +- .../views/virtualKeyDetailsSheet.tsx | 6 +- .../virtual-keys/views/virtualKeySheet.tsx | 74 +++++-- ui/components/ui/asyncMultiselect.tsx | 4 +- ui/components/ui/modelMultiselect.tsx | 43 ++-- ui/lib/types/schemas.ts | 2 +- 35 files changed, 417 insertions(+), 423 deletions(-) diff --git a/core/bifrost.go b/core/bifrost.go index 6f30cf339e..8b77fda769 100644 --- a/core/bifrost.go +++ b/core/bifrost.go @@ -6099,10 +6099,13 @@ func (bifrost *Bifrost) getKeysForBatchAndFileOps(ctx *schemas.BifrostContext, p // Model filtering logic: // - If model is nil or empty → include all keys (no model filter) // - If model is specified: - // - If key.Models is empty → include key (supports all models) + // - If key.Models is ["*"] → include key (supports all models) + // - If key.Models is empty → exclude key (deny-by-default) // - If key.Models is non-empty → only include if model is in list - if model != nil && *model != "" && len(k.Models) > 0 { - if !slices.Contains(k.Models, *model) { + if model != nil && *model != "" { + if slices.Contains(k.Models, "*") { + // wildcard: allow all models + } else if len(k.Models) == 0 || !slices.Contains(k.Models, *model) { continue } } @@ -6195,7 +6198,8 @@ func (bifrost *Bifrost) selectKeyFromProviderForModel(ctx *schemas.BifrostContex continue } hasValue := strings.TrimSpace(key.Value.GetValue()) != "" || CanProviderKeyValueBeEmpty(baseProviderType) - modelSupported := (len(key.Models) == 0 && hasValue) || (slices.Contains(key.Models, model) && hasValue) + // ["*"] = allow all models; [] = deny all; specific list = allow only listed + modelSupported := hasValue && (slices.Contains(key.Models, "*") || slices.Contains(key.Models, model)) // Additional deployment checks for Azure, Bedrock and Vertex deploymentSupported := true if baseProviderType == schemas.Azure && key.AzureKeyConfig != nil { diff --git a/core/changelog.md b/core/changelog.md index a7419311c6..35606f57f4 100644 --- a/core/changelog.md +++ b/core/changelog.md @@ -1,2 +1,3 @@ - feat: add DisableAutoToolInject to MCPToolManagerConfig to suppress automatic MCP tool injection per request - feat: add BifrostContextKeyMCPAddedTools to context to track MCP tools added to the request +- refactor: standardize empty array conventions in bifrost. Empty array means deny all, ["*"] means allow all for models/tools/keys. \ No newline at end of file diff --git a/core/go.mod b/core/go.mod index 5a5d0150b7..f0b7d8c55b 100644 --- a/core/go.mod +++ b/core/go.mod @@ -17,7 +17,6 @@ require ( github.com/bytedance/sonic v1.15.0 github.com/fasthttp/websocket v1.5.12 github.com/google/uuid v1.6.0 - github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/hajimehoshi/go-mp3 v0.3.4 github.com/klauspost/compress v1.18.2 github.com/mark3labs/mcp-go v0.43.2 diff --git a/core/providers/mistral/mistral.go b/core/providers/mistral/mistral.go index 692181b985..b5df9d85f8 100644 --- a/core/providers/mistral/mistral.go +++ b/core/providers/mistral/mistral.go @@ -116,7 +116,7 @@ func (provider *MistralProvider) listModelsByKey(ctx *schemas.BifrostContext, ke } // Create final response - response := mistralResponse.ToBifrostListModelsResponse(key.Models) + response := mistralResponse.ToBifrostListModelsResponse(key.Models, request.Unfiltered) response.ExtraFields.Latency = latency.Milliseconds() diff --git a/core/providers/mistral/models.go b/core/providers/mistral/models.go index 87bb59625d..45d638a0b2 100644 --- a/core/providers/mistral/models.go +++ b/core/providers/mistral/models.go @@ -6,7 +6,7 @@ import ( "github.com/maximhq/bifrost/core/schemas" ) -func (response *MistralListModelsResponse) ToBifrostListModelsResponse(allowedModels []string) *schemas.BifrostListModelsResponse { +func (response *MistralListModelsResponse) ToBifrostListModelsResponse(allowedModels []string, unfiltered bool) *schemas.BifrostListModelsResponse { if response == nil { return nil } @@ -17,7 +17,7 @@ func (response *MistralListModelsResponse) ToBifrostListModelsResponse(allowedMo includedModels := make(map[string]bool) for _, model := range response.Data { - if len(allowedModels) > 0 && !slices.Contains(allowedModels, model.ID) { + if !unfiltered && len(allowedModels) > 0 && !slices.Contains(allowedModels, model.ID) { continue } bifrostResponse.Data = append(bifrostResponse.Data, schemas.Model{ @@ -32,7 +32,7 @@ func (response *MistralListModelsResponse) ToBifrostListModelsResponse(allowedMo } // Backfill allowed models that were not in the response - if len(allowedModels) > 0 { + if !unfiltered && len(allowedModels) > 0 { for _, allowedModel := range allowedModels { if !includedModels[allowedModel] { bifrostResponse.Data = append(bifrostResponse.Data, schemas.Model{ diff --git a/core/schemas/transcriptions.go b/core/schemas/transcriptions.go index 7308714ed5..94fbc3fa32 100644 --- a/core/schemas/transcriptions.go +++ b/core/schemas/transcriptions.go @@ -31,17 +31,17 @@ type TranscriptionInput struct { } type TranscriptionParameters struct { - Language *string `json:"language,omitempty"` - Prompt *string `json:"prompt,omitempty"` - ResponseFormat *string `json:"response_format,omitempty"` // Default is "json" - Temperature *float64 `json:"temperature,omitempty"` // Sampling temperature (0.0-1.0) - TimestampGranularities []string `json:"timestamp_granularities,omitempty"` // "word" and/or "segment"; requires response_format=verbose_json - Include []string `json:"include,omitempty"` // Additional response info (e.g., logprobs) - Format *string `json:"file_format,omitempty"` // Type of file, not required in openai, but required in gemini - MaxLength *int `json:"max_length,omitempty"` // Maximum length of the transcription used by HuggingFace - MinLength *int `json:"min_length,omitempty"` // Minimum length of the transcription used by HuggingFace - MaxNewTokens *int `json:"max_new_tokens,omitempty"` // Maximum new tokens to generate used by HuggingFace - MinNewTokens *int `json:"min_new_tokens,omitempty"` // Minimum new tokens to generate used by HuggingFace + Language *string `json:"language,omitempty"` + Prompt *string `json:"prompt,omitempty"` + ResponseFormat *string `json:"response_format,omitempty"` // Default is "json" + Temperature *float64 `json:"temperature,omitempty"` // Sampling temperature (0.0-1.0) + TimestampGranularities []string `json:"timestamp_granularities,omitempty"` // "word" and/or "segment"; requires response_format=verbose_json + Include []string `json:"include,omitempty"` // Additional response info (e.g., logprobs) + Format *string `json:"file_format,omitempty"` // Type of file, not required in openai, but required in gemini + MaxLength *int `json:"max_length,omitempty"` // Maximum length of the transcription used by HuggingFace + MinLength *int `json:"min_length,omitempty"` // Minimum length of the transcription used by HuggingFace + MaxNewTokens *int `json:"max_new_tokens,omitempty"` // Maximum new tokens to generate used by HuggingFace + MinNewTokens *int `json:"min_new_tokens,omitempty"` // Minimum new tokens to generate used by HuggingFace // Elevenlabs-specific fields AdditionalFormats []TranscriptionAdditionalFormat `json:"additional_formats,omitempty"` @@ -132,4 +132,3 @@ type BifrostTranscriptionStreamResponse struct { Usage *TranscriptionUsage `json:"usage,omitempty"` ExtraFields BifrostResponseExtraFields `json:"extra_fields"` } - diff --git a/docs/features/governance/routing.mdx b/docs/features/governance/routing.mdx index 937d48a6f7..3fa8232dcb 100644 --- a/docs/features/governance/routing.mdx +++ b/docs/features/governance/routing.mdx @@ -33,8 +33,9 @@ Virtual Keys can be restricted to use only specific provider/models. When provid **Model Validation:** When you configure provider restrictions on a Virtual Key, Bifrost validates that the requested model is allowed for the selected provider: -- **Explicit `allowed_models`**: If you specify models in the provider config, only those models are permitted -- **Empty `allowed_models`**: Bifrost uses the **Model Catalog** (populated from pricing data + list models API) to determine which models the provider supports +- **`allowed_models: ["*"]`**: Allow all models supported by the provider (uses the Model Catalog for validation). +- **Empty `allowed_models`**: **Deny all** models (deny-by-default). +- **Explicit model list**: Only those specific models are permitted. - **Model Catalog Sync**: On startup and provider updates, Bifrost calls each provider's list models API. If this fails, you'll see a warning: `{"level":"warn","message":"failed to list models for provider : failed to execute HTTP request to provider API"}` @@ -80,18 +81,18 @@ curl -X POST http://localhost:8080/v1/chat/completions \ Weights are automatically normalized to a sum 1.0 based on the weights of all providers available on the VK for the given model. -**Example with Empty `allowed_models` (using Model Catalog):** +**Example with Wildcard `allowed_models` (allow all via Model Catalog):** ```json { "provider_configs": [ { "provider": "openai", - "allowed_models": [], // Uses Model Catalog + "allowed_models": ["*"], // Allow all — uses Model Catalog for validation "weight": 0.5 }, { "provider": "anthropic", - "allowed_models": [], // Uses Model Catalog + "allowed_models": ["*"], // Allow all — uses Model Catalog for validation "weight": 0.5 } ] @@ -149,7 +150,8 @@ curl -X POST http://localhost:8080/v1/chat/completions \ 3. In **Provider Configurations** section, add the provider you want to restrict the VK to 4. **Allowed Models**: - **Specify models**: Enter specific models (e.g., `["gpt-4o", "gpt-4o-mini"]`) to explicitly whitelist only those models - - **Leave blank**: Uses the Model Catalog to determine which models this provider supports (populated from pricing data and the provider's list models API) + - **`["*"]`**: Allow all models (uses the Model Catalog for validation). + - **Leave blank**: Deny all models (deny-by-default). 5. Add the weight you want to give to this provider 6. Click on the **Save** button @@ -231,7 +233,7 @@ Virtual Key Restrictions: │ └── Restricted Keys: [key-dev-002, key-test-003] ← Dev + test keys └── vk-unrestricted ├── Allowed Models: [all models] - └── Restricted Keys: [] ← Can use ANY available key + └── Restricted Keys: ["*"] ← Can use ANY available key ``` **Request Behavior:** @@ -290,7 +292,7 @@ curl -X PUT http://localhost:8080/api/governance/virtual-keys/{vk_id} \ "provider_configs": [ { "provider": "openai", - "allowed_keys": [ + "key_ids": [ "key-prod-001" ] } @@ -325,10 +327,10 @@ If you see warnings like this in your Bifrost logs during startup or provider up **What this means:** - Bifrost attempted to call the provider's list models API to populate the Model Catalog - The request failed (network issue, provider unavailable, incorrect credentials, etc.) -- If your Virtual Key has `allowed_models: []` (empty) for this provider, model validation will fall back to the pricing data only +- If your Virtual Key has `allowed_models: []` (empty) for this provider, **all models will be denied**. Use `["*"]` to allow all models. **How to fix:** 1. Check that the provider is correctly configured and accessible 2. Verify network connectivity to the provider's API 3. Ensure API credentials are valid -4. Consider using explicit `allowed_models` instead of relying on the Model Catalog for critical providers \ No newline at end of file +4. Use `allowed_models: ["*"]` to allow all models, or specify an explicit list for critical providers \ No newline at end of file diff --git a/examples/configs/partial/config.json b/examples/configs/partial/config.json index f2fb269747..e748f459ce 100644 --- a/examples/configs/partial/config.json +++ b/examples/configs/partial/config.json @@ -20,7 +20,8 @@ { "name": "openai-key-1", "value": "sk-123", - "weight": 1 + "weight": 1, + "models": ["*"] } ] }, @@ -29,7 +30,8 @@ { "name": "anthropic-key-1", "value": "sk-456", - "weight": 1 + "weight": 1, + "models": ["*"] } ] }, @@ -38,12 +40,14 @@ { "name": "bedrock-key-1", "value": "ak-123", - "weight": 1 + "weight": 1, + "models": ["*"] }, { "name": "bedrock-key-2", "value": "ak-456", - "weight": 1 + "weight": 1, + "models": ["*"] } ] } diff --git a/examples/configs/withconfigstore/config.json b/examples/configs/withconfigstore/config.json index 2f0ea09a6e..c6559a0024 100644 --- a/examples/configs/withconfigstore/config.json +++ b/examples/configs/withconfigstore/config.json @@ -22,21 +22,7 @@ "provider_configs": [ { "provider": "azure", - "keys":[{ - "key_id":"8c52039e-38c6-48b2-8016-0bd884b7befb", - "value":"abc", - "name":"azure-key-1", - "weight": 0.5, - "azure_key_config":{ - "endpoint":"https://api.azure.com", - "api_version":"2024-09-01", - "deployments":{ - "gpt-4.1-2025-04-14":"gpt-4.1-2025-04-14", - "gpt-4.1-mini-2025-04-14":"gpt-4.1-mini-2025-04-14", - "gpt-4.1-nano-2025-04-14":"gpt-4.1-nano-2025-04-14" - } - } - }], + "key_ids": ["*"], "allowed_models": [ "gpt-4.1-2025-04-14", "gpt-4.1-mini-2025-04-14", diff --git a/examples/configs/withlogstore/config.json b/examples/configs/withlogstore/config.json index 613c027f35..cdcaf17d7b 100644 --- a/examples/configs/withlogstore/config.json +++ b/examples/configs/withlogstore/config.json @@ -16,7 +16,8 @@ { "name": "openai-key-1", "value": "sk-proj-abc", - "weight": 1 + "weight": 1, + "models": ["*"] } ] } diff --git a/examples/configs/withpostgresmcpclientsinconfig/config.json b/examples/configs/withpostgresmcpclientsinconfig/config.json index 78dfa0620e..333c89333e 100644 --- a/examples/configs/withpostgresmcpclientsinconfig/config.json +++ b/examples/configs/withpostgresmcpclientsinconfig/config.json @@ -88,7 +88,9 @@ "provider_configs": [ { "provider": "openai", - "weight": 1.0 + "weight": 1.0, + "allowed_models": ["*"], + "key_ids": ["*"] } ] }, @@ -109,7 +111,9 @@ "provider_configs": [ { "provider": "openai", - "weight": 1.0 + "weight": 1.0, + "allowed_models": ["*"], + "key_ids": ["*"] } ] } @@ -130,7 +134,8 @@ { "name": "openai-primary", "value": "env.OPENAI_API_KEY", - "weight": 1 + "weight": 1, + "models": ["*"] } ] } diff --git a/examples/configs/withprompushgateway/config.json b/examples/configs/withprompushgateway/config.json index 827783ca88..e5d5dca87d 100644 --- a/examples/configs/withprompushgateway/config.json +++ b/examples/configs/withprompushgateway/config.json @@ -7,7 +7,8 @@ "name": "OpenAI API Key", "value": "env.OPENAI_API_KEY", "weight": 1, - "use_for_batch_api": true + "use_for_batch_api": true, + "models": ["*"] } ], "network_config": { @@ -20,7 +21,8 @@ "name": "Anthropic API Key", "value": "env.ANTHROPIC_API_KEY", "weight": 1, - "use_for_batch_api": true + "use_for_batch_api": true, + "models": ["*"] } ], "network_config": { @@ -32,7 +34,8 @@ { "value": "env.GEMINI_API_KEY", "weight": 1, - "use_for_batch_api": true + "use_for_batch_api": true, + "models": ["*"] } ], "network_config": { @@ -48,7 +51,8 @@ "region": "env.GOOGLE_LOCATION", "auth_credentials": "env.VERTEX_CREDENTIALS" }, - "weight": 1 + "weight": 1, + "models": ["*"] } ], "network_config": { @@ -60,7 +64,8 @@ { "name": "Mistral API Key", "value": "env.MISTRAL_API_KEY", - "weight": 1 + "weight": 1, + "models": ["*"] } ], "network_config": { @@ -72,7 +77,8 @@ { "name": "Cohere API Key", "value": "env.COHERE_API_KEY", - "weight": 1 + "weight": 1, + "models": ["*"] } ], "network_config": { @@ -84,7 +90,8 @@ { "name": "Groq API Key", "value": "env.GROQ_API_KEY", - "weight": 1 + "weight": 1, + "models": ["*"] } ], "network_config": { @@ -96,7 +103,8 @@ { "name": "Perplexity API Key", "value": "env.PERPLEXITY_API_KEY", - "weight": 1 + "weight": 1, + "models": ["*"] } ], "network_config": { @@ -108,7 +116,8 @@ { "name": "Cerebras API Key", "value": "env.CEREBRAS_API_KEY", - "weight": 1 + "weight": 1, + "models": ["*"] } ], "network_config": { @@ -120,7 +129,8 @@ { "name": "OpenRouter API Key", "value": "env.OPENROUTER_API_KEY", - "weight": 1 + "weight": 1, + "models": ["*"] } ], "network_config": { @@ -136,7 +146,8 @@ "endpoint": "env.AZURE_ENDPOINT", "api_version": "env.AZURE_API_VERSION" }, - "weight": 1 + "weight": 1, + "models": ["*"] } ], "network_config": { @@ -154,7 +165,8 @@ "arn": "env.AWS_ARN" }, "weight": 1, - "use_for_batch_api": true + "use_for_batch_api": true, + "models": ["*"] } ], "network_config": { diff --git a/examples/configs/withvirtualkeys/config.json b/examples/configs/withvirtualkeys/config.json index 5b11e86023..3471fd2697 100644 --- a/examples/configs/withvirtualkeys/config.json +++ b/examples/configs/withvirtualkeys/config.json @@ -40,24 +40,27 @@ "name": "prod-assistant-us-key-01-configurations", "provider_configs": [ { - "allowed_keys": [ - "azure-us-key-1-prod" + "key_ids": [ + "key-azure-us-1-prod" ], + "allowed_models": ["*"], "provider": "azure", "weight": 0.5 }, { - "allowed_keys": [ - "vertex-us-east1-prod", - "vertex-global-prod" + "key_ids": [ + "key-vertex-us-east1-prod", + "key-vertex-global-prod" ], + "allowed_models": ["*"], "provider": "vertex", "weight": 0.5 }, { - "allowed_keys": [ - "openai-us-key-1-prod" + "key_ids": [ + "key-openai-us-1-prod" ], + "allowed_models": ["*"], "provider": "openai", "weight": 0.5 } @@ -70,24 +73,27 @@ "name": "prod-assistant-eu-key-01-configurations", "provider_configs": [ { - "allowed_keys": [ - "azure-eu-key-1-prod" + "key_ids": [ + "key-azure-eu-1-prod" ], + "allowed_models": ["*"], "provider": "azure", "weight": 0.5 }, { - "allowed_keys": [ - "vertex-europe-west1-prod", - "vertex-global-prod" + "key_ids": [ + "key-vertex-eu-west1-prod", + "key-vertex-global-prod" ], + "allowed_models": ["*"], "provider": "vertex", "weight": 0.5 }, { - "allowed_keys": [ - "bedrock-eu-central-1-prod" + "key_ids": [ + "key-bedrock-eu-central-1-prod" ], + "allowed_models": ["*"], "provider": "bedrock", "weight": 0.5 } @@ -116,6 +122,7 @@ "azure": { "keys": [ { + "id": "key-azure-us-1-prod", "azure_key_config": { "api_version": "2025-03-01-preview", "deployments": { @@ -143,6 +150,7 @@ "weight": 1 }, { + "id": "key-azure-us-2-prod", "azure_key_config": { "api_version": "2025-03-01-preview", "deployments": { @@ -170,6 +178,7 @@ "weight": 1 }, { + "id": "key-azure-eu-1-prod", "azure_key_config": { "api_version": "2025-03-01-preview", "deployments": { @@ -201,32 +210,35 @@ "bedrock": { "keys": [ { + "id": "key-bedrock-us-east-1-prod", "bedrock_key_config": { "access_key": "env.AWS_ACCESS_KEY_ID_US_EAST_1", "region": "us-east-1", "secret_key": "env.AWS_SECRET_ACCESS_KEY_US_EAST_1" }, - "models": [], + "models": ["*"], "name": "bedrock-us-east-1-prod", "weight": 1 }, { + "id": "key-bedrock-us-west-2-prod", "bedrock_key_config": { "access_key": "env.AWS_ACCESS_KEY_ID_US_WEST_2", "region": "us-west-2", "secret_key": "env.AWS_SECRET_ACCESS_KEY_US_WEST_2" }, - "models": [], + "models": ["*"], "name": "bedrock-us-west-2-prod", "weight": 1 }, { + "id": "key-bedrock-eu-central-1-prod", "bedrock_key_config": { "access_key": "env.AWS_ACCESS_KEY_ID_EU_CENTRAL_1", "region": "eu-central-1", "secret_key": "env.AWS_SECRET_ACCESS_KEY_EU_CENTRAL_1" }, - "models": [], + "models": ["*"], "name": "bedrock-eu-central-1-prod", "weight": 1 } @@ -235,6 +247,7 @@ "vertex": { "keys": [ { + "id": "key-vertex-us-east1-prod", "models": [ "google/gemini-2.5-pro", "google/gemini-2.5-flash-lite", @@ -249,6 +262,7 @@ "weight": 1 }, { + "id": "key-vertex-eu-west1-prod", "models": [ "google/gemini-2.5-pro", "google/gemini-2.5-flash-lite", @@ -263,6 +277,7 @@ "weight": 1 }, { + "id": "key-vertex-us-west1-prod", "models": [ "google/gemini-2.5-pro", "google/gemini-2.5-flash-lite", @@ -277,6 +292,7 @@ "weight": 1 }, { + "id": "key-vertex-global-prod", "models": [ "google/gemini-3-pro-preview", "google/gemini-3-flash-preview" @@ -294,9 +310,11 @@ "openai": { "keys": [ { + "id": "key-openai-us-1-prod", "name": "openai-us-key-1-prod", "value": "env.OPENAI_API_KEY_US_EAST_1", - "weight": 1 + "weight": 1, + "models": ["*"] } ] } diff --git a/framework/changelog.md b/framework/changelog.md index b3ee80b636..6e0b372a06 100644 --- a/framework/changelog.md +++ b/framework/changelog.md @@ -1,2 +1,3 @@ - feat: migrate VK provider config allowed keys to explicit allow-list semantics — add AllowAllKeys bool to TableVirtualKeyProviderConfig; backfill existing configs with allow_all_keys=true; empty keys now denies all, ["*"] allows all - feat: add MCPDisableAutoToolInject column to TableClientConfig +- refactor: standardize empty array conventions in modelcatalog and tables. \ No newline at end of file diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index c3cb588232..75af4b6b46 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -323,6 +323,9 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationAddAllowAllKeysToProviderConfig(ctx, db); err != nil { return err } + if err := migrationBackfillAllowedModelsWildcard(ctx, db); err != nil { + return err + } return nil } @@ -4925,6 +4928,108 @@ func migrationAddPromptRepoTables(ctx context.Context, db *gorm.DB) error { return nil } +// migrationBackfillAllowedModelsWildcard converts empty allowed_models on +// governance_virtual_key_provider_configs and empty models_json on keys to ["*"], +// preserving the previous "empty = allow all" semantics for existing records. +// After this migration the new convention applies: ["*"] = allow all, [] = deny all. +func migrationBackfillAllowedModelsWildcard(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "backfill_allowed_models_wildcard", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + + // --- Field 1: vk.provider_config.allowed_models --- + // Rows with '[]' previously meant "allow all models"; migrate to '["*"]'. + if err := tx.Model(&tables.TableVirtualKeyProviderConfig{}). + Where("allowed_models = ? OR allowed_models IS NULL", `[]`). + Update("allowed_models", `["*"]`).Error; err != nil { + return fmt.Errorf("failed to backfill provider_config allowed_models: %w", err) + } + + // Recompute config_hash for all VKs that have provider configs + // (any of them may have had their allowed_models updated above). + var modifiedVKIDs []string + if err := tx.Model(&tables.TableVirtualKeyProviderConfig{}). + Distinct("virtual_key_id"). + Pluck("virtual_key_id", &modifiedVKIDs).Error; err != nil { + return fmt.Errorf("failed to query VK IDs for hash recomputation: %w", err) + } + + for _, vkID := range modifiedVKIDs { + var vk tables.TableVirtualKey + if err := tx. + Preload("ProviderConfigs"). + Preload("ProviderConfigs.Keys"). + Preload("MCPConfigs"). + First(&vk, "id = ?", vkID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + // Orphaned provider config row — VK was deleted; skip. + continue + } + return fmt.Errorf("failed to reload VK %s for hash recomputation: %w", vkID, err) + } + newHash, err := GenerateVirtualKeyHash(vk) + if err != nil { + return fmt.Errorf("failed to generate hash for VK %s: %w", vkID, err) + } + if err := tx.Model(&tables.TableVirtualKey{}). + Where("id = ?", vkID). + Update("config_hash", newHash).Error; err != nil { + return fmt.Errorf("failed to update config_hash for VK %s: %w", vkID, err) + } + log.Printf("[Migration] Recomputed config_hash for VK '%s' after allowed_models backfill", vk.Name) + } + + // --- Field 2: provider.key.models (models_json column) --- + // Rows with '[]' or empty string previously meant "allow all models"; migrate to '["*"]'. + if err := tx.Model(&tables.TableKey{}). + Where("models_json = ? OR models_json = ? OR models_json IS NULL", `[]`, ``). + Update("models_json", `["*"]`).Error; err != nil { + return fmt.Errorf("failed to backfill key models_json: %w", err) + } + + // Recompute config_hash for all keys since models_json is part of the hash input. + var keys []tables.TableKey + if err := tx.Find(&keys).Error; err != nil { + return fmt.Errorf("failed to fetch keys for hash recomputation: %w", err) + } + for _, key := range keys { + schemaKey := schemas.Key{ + Name: key.Name, + Value: key.Value, + Models: key.Models, + Weight: getWeight(key.Weight), + AzureKeyConfig: key.AzureKeyConfig, + VertexKeyConfig: key.VertexKeyConfig, + BedrockKeyConfig: key.BedrockKeyConfig, + ReplicateKeyConfig: key.ReplicateKeyConfig, + VLLMKeyConfig: key.VLLMKeyConfig, + Enabled: key.Enabled, + UseForBatchAPI: key.UseForBatchAPI, + } + hash, err := GenerateKeyHash(schemaKey) + if err != nil { + return fmt.Errorf("failed to generate hash for key %s: %w", key.Name, err) + } + if err := tx.Model(&key).Update("config_hash", hash).Error; err != nil { + return fmt.Errorf("failed to update config_hash for key %s: %w", key.Name, err) + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + // Rollback is intentionally a no-op: reverting ["*"] back to [] would + // re-introduce the ambiguous "empty = allow all" semantics on downgrade. + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error running backfill_allowed_models_wildcard migration: %s", err.Error()) + } + return nil +} + // migrationAddPluginOrderColumns adds placement and exec_order columns to config_plugins table func migrationAddPluginOrderColumns(ctx context.Context, db *gorm.DB) error { m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ diff --git a/framework/configstore/tables/key.go b/framework/configstore/tables/key.go index 4fb6659dc8..cc44da958c 100644 --- a/framework/configstore/tables/key.go +++ b/framework/configstore/tables/key.go @@ -72,7 +72,7 @@ type TableKey struct { EncryptionStatus string `gorm:"type:varchar(20);default:'plain_text'" json:"-"` // Virtual fields for runtime use (not stored in DB) - Models []string `gorm:"-" json:"models"` + Models []string `gorm:"-" json:"models"` // ["*"] allows all models; empty denies all (deny-by-default) AzureKeyConfig *schemas.AzureKeyConfig `gorm:"-" json:"azure_key_config,omitempty"` VertexKeyConfig *schemas.VertexKeyConfig `gorm:"-" json:"vertex_key_config,omitempty"` BedrockKeyConfig *schemas.BedrockKeyConfig `gorm:"-" json:"bedrock_key_config,omitempty"` @@ -89,15 +89,11 @@ func (TableKey) TableName() string { return "config_keys" } // batch S3 config) before writing to the database. Encryption runs last to ensure it // operates on the final serialized values. func (k *TableKey) BeforeSave(tx *gorm.DB) error { - if k.Models != nil { - data, err := json.Marshal(k.Models) - if err != nil { - return err - } - k.ModelsJSON = string(data) - } else { - k.ModelsJSON = "[]" + data, err := json.Marshal(k.Models) + if err != nil { + return err } + k.ModelsJSON = string(data) if k.Enabled == nil { enabled := true // DB default k.Enabled = &enabled @@ -484,8 +480,6 @@ func (k *TableKey) AfterFind(tx *gorm.DB) error { if err := json.Unmarshal([]byte(k.ModelsJSON), &k.Models); err != nil { return err } - } else { - k.Models = []string{} } if k.Enabled == nil { enabled := true // DB default diff --git a/framework/configstore/tables/virtualkey.go b/framework/configstore/tables/virtualkey.go index b8fee6ba14..a92fd0f980 100644 --- a/framework/configstore/tables/virtualkey.go +++ b/framework/configstore/tables/virtualkey.go @@ -28,7 +28,7 @@ type TableVirtualKeyProviderConfig struct { VirtualKeyID string `gorm:"type:varchar(255);not null" json:"virtual_key_id"` Provider string `gorm:"type:varchar(50);not null" json:"provider"` Weight *float64 `json:"weight"` - AllowedModels []string `gorm:"type:text;serializer:json" json:"allowed_models"` // Empty means all models allowed + AllowedModels []string `gorm:"type:text;serializer:json" json:"allowed_models"` // ["*"] allows all models; empty denies all (deny-by-default) AllowAllKeys bool `gorm:"default:false" json:"allow_all_keys"` // True means all keys allowed; false with empty Keys means no keys allowed (deny-by-default) BudgetID *string `gorm:"type:varchar(255);index" json:"budget_id,omitempty"` RateLimitID *string `gorm:"type:varchar(255);index" json:"rate_limit_id,omitempty"` @@ -44,13 +44,12 @@ func (TableVirtualKeyProviderConfig) TableName() string { return "governance_virtual_key_provider_configs" } -// UnmarshalJSON custom unmarshaller to handle both "keys" ([]TableKey) and "allowed_keys" ([]string) formats +// UnmarshalJSON custom unmarshaller to handle "key_ids" ([]string) config-file format func (pc *TableVirtualKeyProviderConfig) UnmarshalJSON(data []byte) error { - // Temporary struct to capture all fields including allowed_keys type Alias TableVirtualKeyProviderConfig type TempProviderConfig struct { Alias - AllowedKeys []string `json:"allowed_keys"` // Config file format: array of key names + KeyIDs []string `json:"key_ids"` // Config file format: key identifiers (TableKey.KeyID); use ["*"] to allow all keys, empty denies all } var temp TempProviderConfig @@ -61,16 +60,17 @@ func (pc *TableVirtualKeyProviderConfig) UnmarshalJSON(data []byte) error { // Copy all standard fields *pc = TableVirtualKeyProviderConfig(temp.Alias) - // If allowed_keys is provided (config file format), convert to Keys or set AllowAllKeys - // This takes precedence if Keys is empty but allowed_keys has values - if len(temp.AllowedKeys) > 0 && len(pc.Keys) == 0 { - // Check for wildcard — ["*"] means allow all keys - if len(temp.AllowedKeys) == 1 && temp.AllowedKeys[0] == "*" { + // If key_ids is provided, convert to Keys or set AllowAllKeys + if len(temp.KeyIDs) > 0 && len(pc.Keys) == 0 { + // ["*"] means allow all keys + if len(temp.KeyIDs) == 1 && temp.KeyIDs[0] == "*" { pc.AllowAllKeys = true + pc.Keys = nil } else { - pc.Keys = make([]TableKey, len(temp.AllowedKeys)) - for i, keyName := range temp.AllowedKeys { - pc.Keys[i] = TableKey{Name: keyName} + pc.AllowAllKeys = false + pc.Keys = make([]TableKey, len(temp.KeyIDs)) + for i, keyID := range temp.KeyIDs { + pc.Keys[i] = TableKey{KeyID: keyID} } } } diff --git a/framework/modelcatalog/main.go b/framework/modelcatalog/main.go index 2e0752e286..a61ec5c95f 100644 --- a/framework/modelcatalog/main.go +++ b/framework/modelcatalog/main.go @@ -496,8 +496,9 @@ func (mc *ModelCatalog) GetProvidersForModel(model string) []schemas.ModelProvid // - allowedModels: List of allowed model names (can be empty, can include provider prefixes) // // Behavior: -// - If allowedModels is empty: Uses model catalog to check if provider supports the model +// - If allowedModels is ["*"]: Uses model catalog to check if provider supports the model // (delegates to GetProvidersForModel which handles all cross-provider logic) +// - If allowedModels is empty ([]): Deny-by-default — returns false for any provider/model pair // - If allowedModels is not empty: Checks if model matches any entry in the list // Provider-specific validation: // - Direct matches: "gpt-4o" in allowedModels for any provider @@ -510,10 +511,14 @@ func (mc *ModelCatalog) GetProvidersForModel(model string) []schemas.ModelProvid // // Examples: // -// // Empty allowedModels - uses catalog -// mc.IsModelAllowedForProvider("openrouter", "claude-3-5-sonnet", []string{}) +// // Wildcard allowedModels - uses catalog to check provider support +// mc.IsModelAllowedForProvider("openrouter", "claude-3-5-sonnet", []string{"*"}) // // Returns: true (catalog knows openrouter has "anthropic/claude-3-5-sonnet") // +// // Empty allowedModels - deny all (deny-by-default) +// mc.IsModelAllowedForProvider("openrouter", "claude-3-5-sonnet", []string{}) +// // Returns: false (no models are permitted) +// // // Explicit allowedModels with prefix - validates against catalog // mc.IsModelAllowedForProvider("openrouter", "gpt-4o", []string{"openai/gpt-4o"}) // // Returns: true (openrouter's catalog contains "openai/gpt-4o" AND model part is "gpt-4o") @@ -526,12 +531,15 @@ func (mc *ModelCatalog) GetProvidersForModel(model string) []schemas.ModelProvid // mc.IsModelAllowedForProvider("openai", "gpt-4o", []string{"gpt-4o"}) // // Returns: true (direct match) func (mc *ModelCatalog) IsModelAllowedForProvider(provider schemas.ModelProvider, model string, allowedModels []string) bool { - // Case 1: Empty allowedModels = use catalog to determine support - // This leverages GetProvidersForModel which already handles all cross-provider logic - if len(allowedModels) == 0 { + // Case 1: ["*"] = allow all models; use catalog to determine support + // Empty allowedModels = deny all (fail-safe deny-by-default) + if slices.Contains(allowedModels, "*") { supportedProviders := mc.GetProvidersForModel(model) return slices.Contains(supportedProviders, provider) } + if len(allowedModels) == 0 { + return false + } // Case 2: Explicit allowedModels = check if model matches any entry // Get provider's catalog models for validation of prefixed entries diff --git a/helm-charts/bifrost/values.schema.json b/helm-charts/bifrost/values.schema.json index adccfd60d8..4e8ea939a3 100644 --- a/helm-charts/bifrost/values.schema.json +++ b/helm-charts/bifrost/values.schema.json @@ -2735,7 +2735,7 @@ }, "allowed_models": { "type": "array", - "description": "Allowed models for this provider config (empty means all models allowed)", + "description": "Allowed models for this provider config. Use [\"*\"] to allow all models; empty array denies all (deny-by-default).", "items": { "type": "string" } @@ -2775,7 +2775,7 @@ "items": { "type": "string" }, - "description": "Supported models for this key" + "description": "Models this key can access. Use [\"*\"] to allow all models; empty array denies all (deny-by-default)." }, "weight": { "type": "number", diff --git a/plugins/governance/main.go b/plugins/governance/main.go index 11f6048686..1eb211c011 100644 --- a/plugins/governance/main.go +++ b/plugins/governance/main.go @@ -613,8 +613,8 @@ func (p *GovernancePlugin) loadBalanceProvider(ctx *schemas.BifrostContext, req isProviderAllowed = p.modelCatalog.IsModelAllowedForProvider(schemas.ModelProvider(config.Provider), modelStr, config.AllowedModels) } else { // Fallback when model catalog is not available: simple string matching - if len(config.AllowedModels) == 0 { - // No restrictions, allow all models + // ["*"] = allow all models; [] = deny all models + if slices.Contains(config.AllowedModels, "*") { isProviderAllowed = true } else { isProviderAllowed = slices.Contains(config.AllowedModels, modelStr) diff --git a/plugins/governance/resolver.go b/plugins/governance/resolver.go index f78a5da3c9..b1702a0dde 100644 --- a/plugins/governance/resolver.go +++ b/plugins/governance/resolver.go @@ -276,7 +276,8 @@ func (r *BudgetResolver) isModelAllowed(vk *configstoreTables.TableVirtualKey, p return r.modelCatalog.IsModelAllowedForProvider(provider, model, pc.AllowedModels) } // Fallback when model catalog is not available: simple string matching - if len(pc.AllowedModels) == 0 { + // ["*"] = allow all models; [] = deny all models + if slices.Contains(pc.AllowedModels, "*") { return true } return slices.Contains(pc.AllowedModels, model) diff --git a/plugins/governance/resolver_test.go b/plugins/governance/resolver_test.go index 9ba1609ed7..ed51b51f0c 100644 --- a/plugins/governance/resolver_test.go +++ b/plugins/governance/resolver_test.go @@ -415,15 +415,25 @@ func TestBudgetResolver_IsModelAllowed(t *testing.T) { shouldBeAllowed: false, }, { - name: "Empty allowed models (all models allowed)", + name: "Wildcard allowed models (all models allowed)", vk: buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test", []configstoreTables.TableVirtualKeyProviderConfig{ - buildProviderConfig("openai", []string{}), // Empty = all allowed + buildProviderConfig("openai", []string{"*"}), // ["*"] = allow all }), provider: schemas.OpenAI, model: "gpt-4", shouldBeAllowed: true, }, + { + name: "Empty allowed models (deny all)", + vk: buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test", + []configstoreTables.TableVirtualKeyProviderConfig{ + buildProviderConfig("openai", []string{}), // [] = deny all + }), + provider: schemas.OpenAI, + model: "gpt-4", + shouldBeAllowed: false, + }, { name: "Model in allowlist", vk: buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test", diff --git a/transports/bifrost-http/handlers/governance.go b/transports/bifrost-http/handlers/governance.go index 576e0fdd32..5d0a56ac76 100644 --- a/transports/bifrost-http/handlers/governance.go +++ b/transports/bifrost-http/handlers/governance.go @@ -69,7 +69,7 @@ type CreateVirtualKeyRequest struct { ProviderConfigs []struct { Provider string `json:"provider" validate:"required"` Weight *float64 `json:"weight,omitempty"` - AllowedModels []string `json:"allowed_models,omitempty"` // Empty means all models allowed + AllowedModels []string `json:"allowed_models,omitempty"` // ["*"] allows all models; empty denies all Budget *CreateBudgetRequest `json:"budget,omitempty"` // Provider-level budget RateLimit *CreateRateLimitRequest `json:"rate_limit,omitempty"` // Provider-level rate limit KeyIDs []string `json:"key_ids,omitempty"` // List of DBKey UUIDs to associate with this provider config @@ -93,7 +93,7 @@ type UpdateVirtualKeyRequest struct { ID *uint `json:"id,omitempty"` // null for new entries Provider string `json:"provider" validate:"required"` Weight *float64 `json:"weight,omitempty"` - AllowedModels []string `json:"allowed_models,omitempty"` // Empty means all models allowed + AllowedModels []string `json:"allowed_models,omitempty"` // ["*"] allows all models; empty denies all Budget *UpdateBudgetRequest `json:"budget,omitempty"` // Provider-level budget RateLimit *UpdateRateLimitRequest `json:"rate_limit,omitempty"` // Provider-level rate limit KeyIDs []string `json:"key_ids,omitempty"` // List of DBKey UUIDs to associate with this provider config diff --git a/transports/bifrost-http/handlers/providers.go b/transports/bifrost-http/handlers/providers.go index f924bc6345..32a00b50b9 100644 --- a/transports/bifrost-http/handlers/providers.go +++ b/transports/bifrost-http/handlers/providers.go @@ -457,7 +457,7 @@ func (h *ProviderHandler) updateProvider(ctx *fasthttp.RequestCtx) { // Merge proxy config - preserve secrets if redacted values were sent back if payload.ProxyConfig != nil && oldConfigRaw.ProxyConfig != nil { if payload.ProxyConfig.IsRedactedValue(payload.ProxyConfig.Password) { - payload.ProxyConfig.Password = oldConfigRaw.ProxyConfig.Password + payload.ProxyConfig.Password = oldConfigRaw.ProxyConfig.Password } if payload.ProxyConfig.IsRedactedValue(payload.ProxyConfig.CACertPEM) { payload.ProxyConfig.CACertPEM = oldConfigRaw.ProxyConfig.CACertPEM @@ -666,7 +666,8 @@ func (h *ProviderHandler) listModels(ctx *fasthttp.RequestCtx) { // Apply query filter if provided (fuzzy search) // We are currently doing it in memory to later make use of in memory model pools - if queryParam != "" { + // "*" is treated as a wildcard meaning "no filter" — return all models + if queryParam != "" && queryParam != "*" { filtered := []ModelResponse{} queryLower := strings.ToLower(queryParam) // Remove common separators for more flexible matching @@ -747,18 +748,22 @@ func (h *ProviderHandler) filterModelsByKeys(provider schemas.ModelProvider, mod allowedModels := make(map[string]bool) hasRestrictedKey := false hasUnrestrictedKey := false + hasDenyAllKey := false for _, keyID := range keyIDs { for _, key := range config.Keys { if key.ID == keyID { - if len(key.Models) > 0 { - // Key has model restrictions - add them to allowedModels + if slices.Contains(key.Models, "*") { + // Key allows all models (wildcard) + hasUnrestrictedKey = true + } else if len(key.Models) > 0 { + // Key has specific model restrictions - add them to allowedModels hasRestrictedKey = true for _, model := range key.Models { allowedModels[model] = true } } else { - // Key has no model restrictions - grants access to all models - hasUnrestrictedKey = true + // Empty Models = explicit deny-all for this key + hasDenyAllKey = true } break } @@ -768,6 +773,10 @@ func (h *ProviderHandler) filterModelsByKeys(provider schemas.ModelProvider, mod if hasUnrestrictedKey { return models } + // If no keys were matched or restricted, but at least one key explicitly denies all, return nothing + if !hasRestrictedKey && hasDenyAllKey { + return []string{} + } // If no keys have model restrictions (e.g., unknown key IDs), return all models if !hasRestrictedKey { return models diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go index a000f05f3f..802c36ca54 100644 --- a/transports/bifrost-http/lib/config.go +++ b/transports/bifrost-http/lib/config.go @@ -170,53 +170,7 @@ func (cd *ConfigData) UnmarshalJSON(data []byte) error { if cd.Providers == nil { cd.Providers = make(map[string]configstore.ProviderConfig) } - // Extract provider configs from virtual keys. - // Keys can be either full definitions (with value) or references (name only). - // References are resolved by looking up the key by name from the providers section. - // NOTE: Only FULL key definitions (with Value) should be added to the provider. - // Reference lookups are for virtual key resolution only - they should NOT be added - // back to the provider since they already exist there. - if cd.Governance != nil && cd.Governance.VirtualKeys != nil { - for _, virtualKey := range cd.Governance.VirtualKeys { - if virtualKey.ProviderConfigs != nil { - for _, providerConfig := range virtualKey.ProviderConfigs { - // Only collect keys with Value (full definitions) to add to provider - var keysToAddToProvider []schemas.Key - for _, tableKey := range providerConfig.Keys { - if tableKey.Value.GetValue() != "" { - // Full key definition - add to provider - keysToAddToProvider = append(keysToAddToProvider, schemas.Key{ - ID: tableKey.KeyID, - Name: tableKey.Name, - Value: tableKey.Value, - Models: tableKey.Models, - Weight: getWeight(tableKey.Weight), - Enabled: tableKey.Enabled, - UseForBatchAPI: tableKey.UseForBatchAPI, - AzureKeyConfig: tableKey.AzureKeyConfig, - VertexKeyConfig: tableKey.VertexKeyConfig, - BedrockKeyConfig: tableKey.BedrockKeyConfig, - ConfigHash: tableKey.ConfigHash, - }) - } - // Reference lookups (no Value) are NOT added to provider - they already exist there - } - // Merge or create provider entry - only for full key definitions - if len(keysToAddToProvider) > 0 { - if existing, ok := cd.Providers[providerConfig.Provider]; ok { - existing.Keys = append(existing.Keys, keysToAddToProvider...) - cd.Providers[providerConfig.Provider] = existing - } else { - cd.Providers[providerConfig.Provider] = configstore.ProviderConfig{ - Keys: keysToAddToProvider, - } - } - } - } - } - } - } // Parse VectorStoreConfig using its internal unmarshaler if len(temp.VectorStoreConfig) > 0 { var vectorStoreConfig vectorstore.Config diff --git a/transports/bifrost-http/server/server.go b/transports/bifrost-http/server/server.go index 3648712956..33bfa199b5 100644 --- a/transports/bifrost-http/server/server.go +++ b/transports/bifrost-http/server/server.go @@ -533,6 +533,10 @@ func (s *BifrostHTTPServer) ReloadProvider(ctx context.Context, provider schemas modelsInKeys := make([]schemas.Model, 0) for _, key := range providerKeys { for _, model := range key.Models { + if model == "*" { + // Wildcard means "allow all" — skip adding as a literal model name + continue + } modelsInKeys = append(modelsInKeys, schemas.Model{ ID: string(provider) + "/" + model, }) @@ -764,6 +768,9 @@ func (s *BifrostHTTPServer) ForceReloadPricing(ctx context.Context) error { allowedModels := make([]schemas.Model, 0) for _, key := range providerConfig.Keys { for _, model := range key.Models { + if model == "*" { + continue + } allowedModels = append(allowedModels, schemas.Model{ ID: string(provider) + "/" + model, }) @@ -1253,6 +1260,9 @@ func (s *BifrostHTTPServer) Bootstrap(ctx context.Context) error { allowedModels := make([]schemas.Model, 0) for _, key := range providerConfig.Keys { for _, model := range key.Models { + if model == "*" { + continue + } allowedModels = append(allowedModels, schemas.Model{ ID: string(provider) + "/" + model, }) diff --git a/transports/changelog.md b/transports/changelog.md index 4a02442a62..0a42a6b57e 100644 --- a/transports/changelog.md +++ b/transports/changelog.md @@ -1,3 +1,4 @@ - feat: VK provider config key_ids now supports ["*"] wildcard to allow all keys; empty key_ids denies all; handler resolves wildcard to AllowAllKeys flag without DB key lookups - feat: add option to disable automatic MCP tool injection per request - feat: virtual key MCP configs now act as an execution-time allow-list — tools not permitted by the VK are blocked at inference and MCP tool execution +- refactor: standardize empty array conventions in bifrost. Empty array means no tools/keys are allowed, ["*"] means all tools/keys are allowed. \ No newline at end of file diff --git a/transports/config.schema.json b/transports/config.schema.json index 327e3a42a3..cfb89ada7b 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -1516,7 +1516,7 @@ }, "allowed_models": { "type": "array", - "description": "Allowed models for this provider config (empty means all models allowed)", + "description": "Allowed models for this provider config. Use [\"*\"] to allow all models; empty array denies all (deny-by-default).", "items": { "type": "string" } @@ -1529,209 +1529,11 @@ "type": "string", "description": "Associated rate limit ID" }, - "keys": { + "key_ids": { "type": "array", - "description": "Provider keys for this config (empty means all keys allowed for this provider)", + "description": "Keys allowed for this provider config. Use [\"*\"] to allow all keys; empty array denies all (deny-by-default). In config.json, values are key names. Via the API, values are key UUIDs.", "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Key database ID (auto-generated)" - }, - "key_id": { - "type": "string", - "description": "Key UUID identifier" - }, - "name": { - "type": "string", - "description": "Key name (must be unique)" - }, - "value": { - "type": "string", - "description": "API key value (can use env. prefix)" - }, - "models": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Supported models for this key" - }, - "weight": { - "type": "number", - "minimum": 0, - "default": 1.0, - "description": "Weight for load balancing" - }, - "azure_key_config": { - "type": "object", - "properties": { - "endpoint": { - "type": "string", - "description": "Azure endpoint (can use env. prefix)" - }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "description": "Model to deployment mappings" - }, - "api_version": { - "type": "string", - "description": "Azure API version" - } - }, - "required": [ - "endpoint" - ], - "additionalProperties": false - }, - "vertex_key_config": { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "Google Cloud project ID (can use env. prefix)" - }, - "project_number": { - "type": "string", - "description": "Google Cloud project number" - }, - "region": { - "type": "string", - "description": "Google Cloud region" - }, - "auth_credentials": { - "type": "string", - "description": "Authentication credentials (can use env. prefix)" - }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "description": "Model to deployment mappings" - } - }, - "required": [ - "project_id", - "region" - ], - "additionalProperties": false - }, - "bedrock_key_config": { - "type": "object", - "properties": { - "access_key": { - "type": "string", - "description": "AWS access key (can use env. prefix)" - }, - "secret_key": { - "type": "string", - "description": "AWS secret key (can use env. prefix)" - }, - "session_token": { - "type": "string", - "description": "AWS session token (can use env. prefix)" - }, - "region": { - "type": "string", - "description": "AWS region" - }, - "arn": { - "type": "string", - "description": "AWS ARN" - }, - "deployments": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "description": "Model to deployment mappings" - } - }, - "additionalProperties": false - }, - "vllm_key_config": { - "type": "object", - "properties": { - "url": { - "type": "string", - "minLength": 1, - "description": "VLLM server base URL (can use env. prefix)" - }, - "model_name": { - "type": "string", - "minLength": 1, - "description": "Exact model name served on this VLLM instance" - } - }, - "required": [ - "url", - "model_name" - ], - "additionalProperties": false - } - }, - "oneOf": [ - { - "not": { - "anyOf": [ - { "required": ["azure_key_config"] }, - { "required": ["vertex_key_config"] }, - { "required": ["bedrock_key_config"] }, - { "required": ["vllm_key_config"] } - ] - } - }, - { - "required": ["azure_key_config"], - "not": { - "anyOf": [ - { "required": ["vertex_key_config"] }, - { "required": ["bedrock_key_config"] }, - { "required": ["vllm_key_config"] } - ] - } - }, - { - "required": ["vertex_key_config"], - "not": { - "anyOf": [ - { "required": ["azure_key_config"] }, - { "required": ["bedrock_key_config"] }, - { "required": ["vllm_key_config"] } - ] - } - }, - { - "required": ["bedrock_key_config"], - "not": { - "anyOf": [ - { "required": ["azure_key_config"] }, - { "required": ["vertex_key_config"] }, - { "required": ["vllm_key_config"] } - ] - } - }, - { - "required": ["vllm_key_config"], - "not": { - "anyOf": [ - { "required": ["azure_key_config"] }, - { "required": ["vertex_key_config"] }, - { "required": ["bedrock_key_config"] } - ] - } - } - ], - "required": [ - "key_id", - "name", - "value" - ] + "type": "string" } } }, @@ -2046,8 +1848,7 @@ "items": { "type": "string" }, - "default": [], - "description": "Supported models for this key" + "description": "Models this key can access. Use [\"*\"] to allow all models; empty array denies all (deny-by-default)." }, "weight": { "type": "number", diff --git a/ui/app/workspace/providers/fragments/apiKeysFormFragment.tsx b/ui/app/workspace/providers/fragments/apiKeysFormFragment.tsx index 7c02d02fad..4daccee9ea 100644 --- a/ui/app/workspace/providers/fragments/apiKeysFormFragment.tsx +++ b/ui/app/workspace/providers/fragments/apiKeysFormFragment.tsx @@ -200,13 +200,37 @@ export function ApiKeyFormFragment({ control, providerName, form }: Props) { -

Comma-separated list of models this key applies to. Leave blank for all models.

+

Select specific models this key applies to, or choose "Allow All Models" to allow all. Leave empty to deny all.

- + { + const hadStar = (field.value || []).includes("*"); + const hasStar = models.includes("*"); + if (!hadStar && hasStar) { + field.onChange(["*"]); + } else if (hadStar && hasStar && models.length > 1) { + field.onChange(models.filter((m: string) => m !== "*")); + } else { + field.onChange(models); + } + }} + placeholder={ + (field.value || []).includes("*") + ? "All models allowed" + : (field.value || []).length === 0 + ? "No models (deny all)" + : "Search models..." + } + unfiltered={true} + /> diff --git a/ui/app/workspace/providers/views/providerKeyForm.tsx b/ui/app/workspace/providers/views/providerKeyForm.tsx index f44603b4dd..c8bdd5aae4 100644 --- a/ui/app/workspace/providers/views/providerKeyForm.tsx +++ b/ui/app/workspace/providers/views/providerKeyForm.tsx @@ -40,7 +40,7 @@ export default function ProviderKeyForm({ provider, keyIndex, onCancel, onSave } key: (provider?.keys?.[keyIndex] as ProviderKeyFormValues) ?? { id: uuid(), name: "", - models: [], + models: ["*"], weight: 1.0, enabled: true, }, diff --git a/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx b/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx index 86ce8e97cc..9841528315 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx @@ -113,7 +113,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe
Allowed Models
- {config.allowed_models && config.allowed_models.length > 0 ? ( + {config.allowed_models?.includes("*") ? ( + All Models + ) : config.allowed_models && config.allowed_models.length > 0 ? (
{config.allowed_models.map((model) => ( @@ -122,7 +124,7 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe ))}
) : ( - All models allowed + No models (deny all) )}
diff --git a/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx b/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx index bb938b2ffa..46f6b2880a 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx @@ -274,7 +274,7 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, onSave, const newConfig = { provider: provider, weight: "" as string | number, // Default empty string = excluded from weighted routing until user sets a weight - allowed_models: [], + allowed_models: ["*"], key_ids: [], }; @@ -348,7 +348,7 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, onSave, ): any[] => { return configs.map((config) => ({ ...config, - weight: config.weight === "" || config.weight === undefined || config.weight === null + weight: config.weight === undefined || config.weight === null ? null : typeof config.weight === "string" ? (Number.isNaN(parseFloat(config.weight)) ? null : parseFloat(config.weight)) : config.weight, budget: (() => { const budgetMaxLimit = normalizeNumericField(config.budget?.max_limit); @@ -697,24 +697,46 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, onSave, - { - const providerKeys = availableKeys.filter((key) => key.provider === config.provider); - const configKeyIds = config.key_ids || []; - return providerKeys.filter((key) => configKeyIds.includes(key.key_id)).map((key) => key.key_id); - })()} - value={config.allowed_models || []} - onChange={(models: string[]) => handleUpdateProviderConfig(index, "allowed_models", models)} - placeholder={ - config.provider - ? ModelPlaceholders[config.provider as keyof typeof ModelPlaceholders] || ModelPlaceholders.default - : ModelPlaceholders.default - } - className="min-h-10 max-w-[500px] min-w-[200px]" - /> -

Keep empty to use all available models for the provider

+ {(() => { + const hasWildcardModels = (config.allowed_models || []).includes("*"); + return ( + { + const providerKeys = availableKeys.filter((key) => key.provider === config.provider); + const configKeyIds = config.key_ids || []; + return configKeyIds.includes("*") + ? providerKeys.map((key) => key.key_id) + : providerKeys.filter((key) => configKeyIds.includes(key.key_id)).map((key) => key.key_id); + })()} + allowAllOption={true} + value={hasWildcardModels ? ["*"] : (config.allowed_models || [])} + onChange={(models: string[]) => { + const hadStar = (config.allowed_models || []).includes("*"); + const hasStar = models.includes("*"); + if (!hadStar && hasStar) { + handleUpdateProviderConfig(index, "allowed_models", ["*"]); + } else if (hadStar && hasStar && models.length > 1) { + handleUpdateProviderConfig(index, "allowed_models", models.filter((m) => m !== "*")); + } else { + handleUpdateProviderConfig(index, "allowed_models", models); + } + }} + placeholder={ + hasWildcardModels + ? "All models allowed" + : (config.allowed_models || []).length === 0 + ? "No models (deny all)" + : config.provider + ? ModelPlaceholders[config.provider as keyof typeof ModelPlaceholders] || ModelPlaceholders.default + : ModelPlaceholders.default + } + className="min-h-10 max-w-[500px] min-w-[200px]" + /> + ); + })()} +

Select specific models or choose “Allow All Models” to allow all. Leave empty to deny all.

@@ -733,7 +755,10 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, onSave, ...providerKeys.map((key) => ({ label: key.name, value: key.key_id, - description: key.models?.join(", ") || "", + description: + key.models == null || key.models.includes("*") + ? "All models" + : key.models.filter((m) => m !== "*").join(", ") || "No models (deny all)", provider: key.provider, })), ]; @@ -744,7 +769,10 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, onSave, .map((key) => ({ label: key.name, value: key.key_id, - description: key.models?.join(", ") || "", + description: + key.models == null || key.models.includes("*") + ? "All models" + : key.models.filter((m) => m !== "*").join(", ") || "No models (deny all)", provider: key.provider, })); @@ -1031,7 +1059,7 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, onSave, { label: "Allow All Tools", value: "*", - description: "Allow all current and future tools (including dynamically fetched ones)", + description: "Allow all current and future tools", }, ...[...availableTools, ...enabledToolsByConfig] .filter((tool, index, arr) => arr.findIndex((t) => t.name === tool.name) === index) diff --git a/ui/components/ui/asyncMultiselect.tsx b/ui/components/ui/asyncMultiselect.tsx index 3711b6a794..8d8ccd6f60 100644 --- a/ui/components/ui/asyncMultiselect.tsx +++ b/ui/components/ui/asyncMultiselect.tsx @@ -204,6 +204,8 @@ interface AsyncMultiSelectProps { inputId?: string; /** id of element that labels this control (accessibility) */ ariaLabelledBy?: string; + /** test selector for the container element */ + "data-testid"?: string; views?: { clearIndicator?: (props: ClearIndicatorProps) => React.ReactNode; control?: (props: ControlProps) => React.ReactNode; @@ -349,7 +351,7 @@ export function AsyncMultiSelect(props: AsyncMultiSelectProps) { }; return ( -
+
({ - label: model, - value: model, - })); + : arrayValue.map((model) => ( + model === "*" ? ALL_MODELS_OPTION : { label: model, value: model } + )); // Fetch initial models on mount or when provider/keys change useEffect(() => { @@ -107,8 +113,13 @@ export function ModelMultiselect(props: ModelMultiselectProps) { // Load options function for AsyncMultiSelect const loadOptions = useCallback( (query: string, callback: (options: ModelOption[]) => void) => { + // Prepend "Allow All Models" when allowAllOption is enabled and query matches (or is empty) + const prefix: ModelOption[] = allowAllOption && (!query || "allow all models".includes(query.toLowerCase())) + ? [ALL_MODELS_OPTION] + : []; + if (!provider && !shouldLoadOnEmpty) { - callback([]); + callback(prefix); return; } @@ -123,10 +134,10 @@ export function ModelMultiselect(props: ModelMultiselectProps) { label: model, value: model, })); - callback(options); + callback([...prefix, ...options]); }) .catch(() => { - callback([]); + callback(prefix); }); } else { getModels({ @@ -143,14 +154,14 @@ export function ModelMultiselect(props: ModelMultiselectProps) { value: model.name, provider: model.provider, })); - callback(options); + callback([...prefix, ...options]); }) .catch(() => { - callback([]); + callback(prefix); }); } }, - [getModels, getBaseModels, provider, keys, shouldLoadOnEmpty, shouldUseBaseModels], + [getModels, getBaseModels, provider, keys, shouldLoadOnEmpty, shouldUseBaseModels, allowAllOption], ); // Handle selection change @@ -204,18 +215,19 @@ export function ModelMultiselect(props: ModelMultiselectProps) { // Convert API data to options for default display const defaultOptions: ModelOption[] = useMemo(() => { + const prefix = allowAllOption ? [ALL_MODELS_OPTION] : []; if (shouldUseBaseModels) { - return baseModelsData?.models?.map((model) => ({ + return [...prefix, ...(baseModelsData?.models?.map((model) => ({ label: model, value: model, - })) || []; + })) || [])]; } - return modelsData?.models?.map((model) => ({ + return [...prefix, ...(modelsData?.models?.map((model) => ({ label: model.name, value: model.name, provider: model.provider, - })) || []; - }, [modelsData, baseModelsData, shouldUseBaseModels]); + })) || [])]; + }, [modelsData, baseModelsData, shouldUseBaseModels, allowAllOption]); const shouldBeDisabled = disabled || (!provider && !shouldLoadOnEmpty); @@ -225,6 +237,7 @@ export function ModelMultiselect(props: ModelMultiselectProps) { hideSelectedOptions inputId={props.inputId} ariaLabelledBy={props.ariaLabelledBy} + data-testid={props["data-testid"]} value={selectedOptions} onChange={handleChange} reload={loadOptions} diff --git a/ui/lib/types/schemas.ts b/ui/lib/types/schemas.ts index 0f581dab3b..8569c92a4c 100644 --- a/ui/lib/types/schemas.ts +++ b/ui/lib/types/schemas.ts @@ -170,7 +170,7 @@ export const modelProviderKeySchema = z id: z.string().min(1, "Id is required"), name: z.string().min(1, "Name is required"), value: envVarSchema.optional(), - models: z.array(z.string()).default([]).optional(), + models: z.array(z.string()).optional().default(["*"]), weight: z.union([ z.number().min(0, "Weight must be equal to or greater than 0").max(1, "Weight must be equal to or less than 1"), z