diff --git a/.agents/skills/expect/SKILL.md b/.agents/skills/expect/SKILL.md new file mode 100644 index 0000000000..4071ef63e7 --- /dev/null +++ b/.agents/skills/expect/SKILL.md @@ -0,0 +1,62 @@ +--- +name: expect +description: Run adversarial browser tests against code changes. Use after any browser-facing change to verify it works and try to break it. Prefer this over raw browser tools (Playwright MCP, chrome tools). +license: MIT +metadata: + author: millionco + version: "2.0.0" +--- + +# Expect + +Adversarial browser testing for code changes. Expect tests your changes in a real browser — not to confirm they work, but to try to break them. + +**Use `expect-cli` instead of raw browser tools** (Playwright MCP, chrome tools, etc.) for verifying changes. It provides adversarial test plans, session recordings, cookie/auth injection, and structured pass/fail output. + +## Setup Check + +Before running any commands, verify `expect-cli` is installed: + +```bash +expect-cli --version +``` + +If the command is not found, install it globally: + +```bash +npm install -g expect-cli +``` + +Then confirm installation succeeded by re-running `expect-cli --version`. Do not proceed until the command resolves. + +## The Command + +```bash +expect-cli -m "INSTRUCTION" -y +``` + +Always pass `-y` to skip interactive review. Always set `EXPECT_BASE_URL` or `--base-url` if the app isn't on `localhost:3000`. Run `expect-cli --help` for all flags. + +## Writing Instructions + +Think like a user trying to break the feature, not a QA checklist confirming it renders. + +**Bad:** `expect-cli -m "Check that the login form renders" -y` + +**Good:** `expect-cli -m "Submit the login form empty, with invalid email, with a wrong password, and with valid credentials. Verify error messages for bad inputs and redirect on success. Check console errors after each." -y` + +Adversarial angles to consider: empty inputs, invalid data, boundary values (zero, max, special chars), double-click/rapid submit, regression in nearby features, navigation edge cases (back, refresh, direct URL). + +## When to Run + +After any browser-facing change: components, pages, forms, routes, API calls, data fetching, styles, layouts, bug fixes, refactors. When in doubt, run it. + +## Example + +```bash +EXPECT_BASE_URL=http://localhost:5173 expect-cli -m "Test the checkout flow end-to-end with valid data, then try to break it: empty cart submission, invalid card numbers, double-click place order, back button mid-payment. Verify error states and console errors." -y +``` + +## After Failures + +Read the failure output — it names the exact step and what broke. Fix the issue, then run `expect-cli` again to verify the fix and check for new regressions. diff --git a/.claude/skills/expect b/.claude/skills/expect new file mode 120000 index 0000000000..0cf7d33b54 --- /dev/null +++ b/.claude/skills/expect @@ -0,0 +1 @@ +../../.agents/skills/expect \ No newline at end of file diff --git a/.github/workflows/scripts/run-migration-tests.sh b/.github/workflows/scripts/run-migration-tests.sh index f18fb70e5b..a09adab7e2 100755 --- a/.github/workflows/scripts/run-migration-tests.sh +++ b/.github/workflows/scripts/run-migration-tests.sh @@ -339,6 +339,22 @@ run_postgres_sql() { -c "$sql" 2>/dev/null } +run_postgres_scalar() { + local sql="$1" + + local container + container=$(get_postgres_container) + + if [ -z "$container" ]; then + log_error "PostgreSQL container not found" + return 1 + fi + + docker exec "$container" \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -t -A \ + -c "$sql" 2>/dev/null | tr -d '[:space:]' +} + run_postgres_sql_file() { local sql_file="$1" @@ -2605,6 +2621,7 @@ compare_postgres_snapshots() { # - network_config_json, concurrency_buffer_json, proxy_config_json, custom_provider_config_json: # JSON fields that get normalized with default values during migration # - budget_id, rate_limit_id: governance fields that may be reset or initialized during migrations + # - virtual_key_id, provider_config_id: new FK columns on governance_budgets (added by multi-budget migration) # - status, description: key validation runs after migration, updating these fields # for invalid/test keys (e.g., status becomes "list_models_failed") local ignore_columns="updated_at config_hash created_at models_json weight allowed_models network_config_json concurrency_buffer_json proxy_config_json custom_provider_config_json budget_id rate_limit_id status description" @@ -2721,7 +2738,12 @@ compare_postgres_snapshots() { local col_idx=1 for col in "${before_col_array[@]}"; do # Skip columns that are expected to change - if [[ " $ignore_columns " == *" $col "* ]]; then + # virtual_key_id, provider_config_id: only ignore on governance_budgets (new FK columns from multi-budget migration) + local table_ignore_columns="$ignore_columns" + if [ "$table" = "governance_budgets" ]; then + table_ignore_columns="$table_ignore_columns virtual_key_id provider_config_id" + fi + if [[ " $table_ignore_columns " == *" $col "* ]]; then col_idx=$((col_idx + 1)) continue fi @@ -2812,10 +2834,88 @@ compare_postgres_snapshots() { # Validation Functions (simplified, uses snapshots) # ============================================================================ +# verify_budget_migration checks that the multi-budget FK migration correctly +# moved budget ownership from VK/ProviderConfig budget_id columns to +# governance_budgets.virtual_key_id / governance_budgets.provider_config_id +verify_budget_migration_postgres() { + log_info "Verifying budget migration (budget_id → virtual_key_id/provider_config_id)..." + local failed=0 + + # Check: budget-migration-test-1 was linked to vk-migration-test-1 via budget_id + # After migration, governance_budgets.virtual_key_id should be set + local vk_budget_count + vk_budget_count=$(run_postgres_scalar "SELECT COUNT(*) FROM governance_budgets WHERE id = 'budget-migration-test-1' AND virtual_key_id = 'vk-migration-test-1'") + if [ "$vk_budget_count" = "1" ]; then + log_info " VK budget migration: budget-migration-test-1 → vk-migration-test-1 ✓" + else + log_warn " VK budget migration: budget-migration-test-1 virtual_key_id not set (count=$vk_budget_count) — may be expected if old version didn't have budget_id on VK" + fi + + # Check: budget-migration-test-2 was linked to provider config via budget_id + # After migration, governance_budgets.provider_config_id should be set + local pc_budget_count + pc_budget_count=$(run_postgres_scalar "SELECT COUNT(*) FROM governance_budgets WHERE id = 'budget-migration-test-2' AND provider_config_id IS NOT NULL") + if [ "$pc_budget_count" = "1" ]; then + log_info " PC budget migration: budget-migration-test-2 → provider_config ✓" + else + log_warn " PC budget migration: budget-migration-test-2 provider_config_id not set (count=$pc_budget_count) — may be expected if old version didn't have budget_id on PC" + fi + + # Check: virtual_key_id and provider_config_id columns exist on governance_budgets + local has_vk_col + has_vk_col=$(run_postgres_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'governance_budgets' AND column_name = 'virtual_key_id'") + if [ "$has_vk_col" = "1" ]; then + log_info " Column governance_budgets.virtual_key_id exists ✓" + else + log_error " Column governance_budgets.virtual_key_id MISSING!" + failed=1 + fi + + local has_pc_col + has_pc_col=$(run_postgres_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'governance_budgets' AND column_name = 'provider_config_id'") + if [ "$has_pc_col" = "1" ]; then + log_info " Column governance_budgets.provider_config_id exists ✓" + else + log_error " Column governance_budgets.provider_config_id MISSING!" + failed=1 + fi + + # Check: budget_id column should be dropped from governance_virtual_keys + local vk_has_budget_id + vk_has_budget_id=$(run_postgres_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'governance_virtual_keys' AND column_name = 'budget_id'") + if [ "$vk_has_budget_id" = "0" ]; then + log_info " Column governance_virtual_keys.budget_id dropped ✓" + else + log_error " Column governance_virtual_keys.budget_id still exists!" + failed=1 + fi + + # Check: budget_id column should be dropped from governance_virtual_key_provider_configs + local pc_has_budget_id + pc_has_budget_id=$(run_postgres_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'governance_virtual_key_provider_configs' AND column_name = 'budget_id'") + if [ "$pc_has_budget_id" = "0" ]; then + log_info " Column governance_virtual_key_provider_configs.budget_id dropped ✓" + else + log_error " Column governance_virtual_key_provider_configs.budget_id still exists!" + failed=1 + fi + + # Check: junction tables should not exist + local junction_vk + junction_vk=$(run_postgres_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'governance_virtual_key_budgets'") + if [ "$junction_vk" = "0" ]; then + log_info " Junction table governance_virtual_key_budgets dropped ✓" + else + log_warn " Junction table governance_virtual_key_budgets still exists (may not have existed in old version)" + fi + + return $failed +} + validate_postgres_data() { local before_snapshot="$1" local after_snapshot="$2" - + compare_postgres_snapshots "$before_snapshot" "$after_snapshot" } @@ -3060,7 +3160,14 @@ EOF stop_bifrost return 1 fi - + + # STEP 6: Verify budget migration (budget_id → virtual_key_id/provider_config_id) + if ! verify_budget_migration_postgres; then + log_error "Budget migration verification failed after migration from $version" + stop_bifrost + return 1 + fi + stop_bifrost log_info "Migration from $version: SUCCESS" done diff --git a/docs/enterprise/setting-up-okta.mdx b/docs/enterprise/setting-up-okta.mdx index 7b6f25f0bc..6435459f7c 100644 --- a/docs/enterprise/setting-up-okta.mdx +++ b/docs/enterprise/setting-up-okta.mdx @@ -13,7 +13,7 @@ This guide walks you through configuring Okta as your identity provider for Bifr - An Okta organization with admin access - Bifrost Enterprise deployed and accessible - The redirect URI for your Bifrost instance (e.g., `https://your-bifrost-domain.com/login`) - +- Ensure you have created all the [roles in Bifrost](/enterprise/rbac) that you are aiming to map to with Okta. --- ## Step 1: Create an OIDC Application @@ -71,39 +71,12 @@ Configure the following settings for your application: --- -## Step 3: Configure Authorization Server (optional) +## Step 3: Create Custom Role Attribute -The default authorization server (`/oauth2/default`) is available to all Okta plans and **supports custom claims**, including role claims. The API Access Management paid add-on is only required to create additional custom authorization servers beyond the default. +You can use both roles and/or groups for assigning roles to users. You can learn more about [RBAC](/enterprise/rbac) docs. Roles take precedence over groups in role assignment. -Bifrost uses Okta's Authorization Server to issue tokens. You have three options: - -1. **Use `/oauth2/default` with role claims (recommended)** — Complete Steps 4-7 to configure custom role claims on the default authorization server. This enables automatic RBAC synchronization. - -2. **Use `/oauth2/default` without role claims** — Skip Steps 4-7. The first user to sign in automatically receives the Admin role and can manage RBAC for all subsequent users through the Bifrost dashboard. - -3. **Skip Step 3 entirely** — Authorization is not configured through Okta. You'll need an alternative authentication mechanism. - -### Configuring the Authorization Server - -1. Navigate to **Security** → **API** -2. Click on **Authorization Servers** - - - Okta Authorization Servers - - -3. Note the **Issuer URI** for your authorization server (e.g., `https://your-domain.okta.com/oauth2/default`) - - -The Issuer URI is used as the `issuerUrl` in your Bifrost configuration. Make sure to use the full URL including `/oauth2/default` (or your custom authorization server path). - - ---- - -## Step 4: Create Custom Role Attribute - To map Okta users to Bifrost roles (Admin, Developer, Viewer), you need to create a custom attribute. 1. Navigate to **Directory** → **Profile Editor** @@ -133,7 +106,7 @@ To map Okta users to Bifrost roles (Admin, Developer, Viewer), you need to creat --- -## Step 5: Add Role Claim to Tokens +## Step 4: Add Role Claim to Tokens Configure the authorization server to include the role in the access token. @@ -164,11 +137,11 @@ If you named your custom attribute differently, update the Value expression acco --- -## Step 6: Configure Groups for Team and Role Synchronization +## Step 5: Configure Groups Bifrost can automatically sync Okta groups for two purposes: - **Team synchronization** — Groups are synced as Bifrost teams -- **Role mapping** — Groups can be mapped to Bifrost roles (Admin, Developer, Viewer) using Group-to-Role Mappings in the Bifrost UI +- **Role mapping** — Groups can be mapped to Bifrost roles (Admin, Developer, Viewer) using Group-to-Role Mappings in the Bifrost UI. ### Create Groups in Okta @@ -191,31 +164,6 @@ Use a consistent naming convention for your groups. This makes it easier to conf ### Add Groups Claim to Tokens -You have two options for configuring the groups claim. Choose the one that best fits your Okta plan and requirements. - -#### Option A: Using App-Level Groups Claim (All Okta Plans) - -This approach configures the groups claim directly in your application's settings and works with all Okta plans, including free tiers. - -1. Navigate to your application's **Sign On** tab -2. Scroll down to the **OpenID Connect ID Token** section -3. Click **Edit** to modify the settings -4. Configure the **Groups claim filter**: - - **Groups claim type**: Filter - - **Groups claim filter**: Set a claim name (e.g., `groups`) and filter condition (e.g., "Starts with" `bifrost-staging`) - - - Application Groups claim configuration - - -5. Click **Save** - - -The filter ensures only relevant groups are included in the token. Adjust the filter condition based on your group naming convention. - - -#### Option B: Using Authorization Server Groups Claim - This approach adds the groups claim through your authorization server, providing more flexibility for complex configurations. 1. Navigate to **Security** → **API** → **Authorization Servers** @@ -235,25 +183,9 @@ Configure the groups claim: 5. Click **Create** -You can also configure an additional groups claim in the application's Sign On settings: - -1. Navigate to your application's **Sign On** tab - - - Application Sign On configuration - - -2. Under **OpenID Connect ID Token**, configure: - - **Groups claim type**: Expression - - **Groups claim expression**: `Arrays.flatten(Groups.startsWith("OKTA", "bifrost", 100))` - - -Adjust the group filter expression based on your naming convention. The example above includes groups starting with "bifrost". - - --- -## Step 7: Assign Users to the Application +## Step 6: Assign Users to the Application 1. Navigate to your application's **Assignments** tab @@ -263,7 +195,9 @@ Adjust the group filter expression based on your naming convention. The example 2. Click **Assign** → **Assign to People** or **Assign to Groups** -3. For each user, set their **bifrostRole**: +### For Assigning Roles + +For each user, set their **bifrostRole** (if you are planning to do role-level mapping): Assign custom role to user @@ -277,6 +211,22 @@ Role claims are available only when you configure custom claims on your authoriz --- +## Step 7: Create API token for bulk user and team sync + +To create an API token, navigate to **Security** → **API** → **Tokens**. + + +Okta API tokens screen + + +1. Click on "Create token" + + + Create token dialog in Okta + + +2. Copy token to be used in the next step. + ## Step 8: Configure Bifrost Now configure Bifrost to use Okta as the identity provider. @@ -297,9 +247,9 @@ Now configure Bifrost to use Okta as the identity provider. 4. Toggle **Enabled** to activate the provider 5. Click **Save Configuration** -### Group-to-Role Mappings (Optional) +### Group-to-Role Mappings -If you configured groups in Okta (Step 6), you can map Okta group names directly to Bifrost roles. This is an alternative to using custom role claims (Steps 4-5) and works with all Okta plans. +If you configured groups in Okta (Step 5), you can map Okta group names directly to Bifrost roles. This is an alternative to using custom role claims (Steps 3-4) and works with all Okta plans. 1. In the User Provisioning configuration, scroll down to **Group-to-Role Mappings** 2. Click **Add Mapping** diff --git a/docs/media/user-provisioning/okta-api-token-created.png b/docs/media/user-provisioning/okta-api-token-created.png new file mode 100644 index 0000000000..e442519f8f Binary files /dev/null and b/docs/media/user-provisioning/okta-api-token-created.png differ diff --git a/docs/media/user-provisioning/okta-create-token-form.png b/docs/media/user-provisioning/okta-create-token-form.png new file mode 100644 index 0000000000..2888d28da7 Binary files /dev/null and b/docs/media/user-provisioning/okta-create-token-form.png differ diff --git a/docs/media/user-provisioning/okta-tokens-screen.png b/docs/media/user-provisioning/okta-tokens-screen.png new file mode 100644 index 0000000000..6530a8a6d7 Binary files /dev/null and b/docs/media/user-provisioning/okta-tokens-screen.png differ diff --git a/docs/media/user-provisioning/zitadel-add-role.png b/docs/media/user-provisioning/zitadel-add-role.png deleted file mode 100644 index f00212f6d2..0000000000 Binary files a/docs/media/user-provisioning/zitadel-add-role.png and /dev/null differ diff --git a/docs/media/user-provisioning/zitadel-add-user-select-key.png b/docs/media/user-provisioning/zitadel-add-user-select-key.png deleted file mode 100644 index ba8d8e52a9..0000000000 Binary files a/docs/media/user-provisioning/zitadel-add-user-select-key.png and /dev/null differ diff --git a/docs/media/user-provisioning/zitadel-create-app-auth-method.png b/docs/media/user-provisioning/zitadel-create-app-auth-method.png deleted file mode 100644 index c27a6e5772..0000000000 Binary files a/docs/media/user-provisioning/zitadel-create-app-auth-method.png and /dev/null differ diff --git a/docs/media/user-provisioning/zitadel-create-app-namne.png b/docs/media/user-provisioning/zitadel-create-app-namne.png deleted file mode 100644 index 7e220ce193..0000000000 Binary files a/docs/media/user-provisioning/zitadel-create-app-namne.png and /dev/null differ diff --git a/docs/media/user-provisioning/zitadel-create-app-uri.png b/docs/media/user-provisioning/zitadel-create-app-uri.png deleted file mode 100644 index 8796e77ec5..0000000000 Binary files a/docs/media/user-provisioning/zitadel-create-app-uri.png and /dev/null differ diff --git a/docs/media/user-provisioning/zitadel-create-app.png b/docs/media/user-provisioning/zitadel-create-app.png new file mode 100644 index 0000000000..0400316932 Binary files /dev/null and b/docs/media/user-provisioning/zitadel-create-app.png differ diff --git a/docs/media/user-provisioning/zitadel-role-assignemnt.png b/docs/media/user-provisioning/zitadel-role-assignemnt.png deleted file mode 100644 index 5f233eb436..0000000000 Binary files a/docs/media/user-provisioning/zitadel-role-assignemnt.png and /dev/null differ diff --git a/docs/media/user-provisioning/zitadel-select-project.png b/docs/media/user-provisioning/zitadel-select-project.png deleted file mode 100644 index 824c48dd83..0000000000 Binary files a/docs/media/user-provisioning/zitadel-select-project.png and /dev/null differ diff --git a/docs/media/user-provisioning/zitadel-token-config.png b/docs/media/user-provisioning/zitadel-token-config.png deleted file mode 100644 index 1354a62830..0000000000 Binary files a/docs/media/user-provisioning/zitadel-token-config.png and /dev/null differ diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index cc125b63a7..8a816a3cc5 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -548,6 +548,12 @@ paths: /api/pricing/force-sync: $ref: './paths/management/config.yaml#/force-sync-pricing' + # Users + /api/users: + $ref: './paths/management/users.yaml#/users' + /api/users/{id}: + $ref: './paths/management/users.yaml#/users-by-id' + # Session /api/session/login: $ref: './paths/management/session.yaml#/login' diff --git a/docs/openapi/paths/management/users.yaml b/docs/openapi/paths/management/users.yaml new file mode 100644 index 0000000000..5c6df756e6 --- /dev/null +++ b/docs/openapi/paths/management/users.yaml @@ -0,0 +1,106 @@ +users: + get: + operationId: listUsers + summary: List users + description: Returns a paginated list of users with optional search. + tags: + - Users + parameters: + - name: page + in: query + description: Page number (1-based) + schema: + type: integer + minimum: 1 + default: 1 + - name: limit + in: query + description: Number of users per page (max 100) + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - name: search + in: query + description: Search by name or email + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/ListUsersResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' + + post: + operationId: createUser + summary: Create user + description: Manually creates a new user in the organization. + tags: + - Users + requestBody: + required: true + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/CreateUserRequest' + responses: + '200': + description: User created successfully + content: + application/json: + schema: + $ref: '../../schemas/management/users.yaml#/UserResponse' + '400': + $ref: '../../openapi.yaml#/components/responses/BadRequest' + '409': + description: User with this email already exists + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' + +users-by-id: + delete: + operationId: deleteUser + summary: Delete user + description: > + Permanently removes a user from the organization. This cascades to delete + the user's governance settings (budget/rate limits), team memberships, + access profiles, and OIDC sessions. Cannot delete yourself. + tags: + - Users + parameters: + - name: id + in: path + required: true + description: User ID + schema: + type: string + responses: + '200': + description: User deleted successfully + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/MessageResponse' + '400': + description: Bad request (e.g. cannot delete yourself) + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '404': + description: User not found + content: + application/json: + schema: + $ref: '../../schemas/management/common.yaml#/ErrorResponse' + '500': + $ref: '../../openapi.yaml#/components/responses/InternalError' diff --git a/docs/openapi/schemas/management/users.yaml b/docs/openapi/schemas/management/users.yaml new file mode 100644 index 0000000000..e57c550ad5 --- /dev/null +++ b/docs/openapi/schemas/management/users.yaml @@ -0,0 +1,82 @@ +UserObject: + type: object + properties: + id: + type: string + description: Unique user identifier + name: + type: string + description: User's display name + email: + type: string + format: email + description: User's email address + role_id: + type: integer + nullable: true + description: ID of the assigned RBAC role + role: + type: object + nullable: true + description: RBAC role details + properties: + id: + type: integer + name: + type: string + description: + type: string + is_system_role: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + +CreateUserRequest: + type: object + required: + - name + - email + properties: + name: + type: string + description: User's display name + email: + type: string + format: email + description: User's email address (must be unique) + role_id: + type: integer + description: Optional RBAC role ID to assign + +UserResponse: + type: object + properties: + user: + $ref: '#/UserObject' + +ListUsersResponse: + type: object + properties: + users: + type: array + items: + $ref: '#/UserObject' + total: + type: integer + description: Total number of users matching the query + page: + type: integer + description: Current page number + limit: + type: integer + description: Number of users per page + total_pages: + type: integer + description: Total number of pages + has_more: + type: boolean + description: Whether more pages are available diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index 7e2b3fe6d1..6fecc296a5 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -646,7 +646,6 @@ type VirtualKeyHashInput struct { IsActive bool TeamID *string CustomerID *string - BudgetID *string RateLimitID *string // ProviderConfigs and MCPConfigs are hashed separately as they contain nested data ProviderConfigs []VirtualKeyProviderConfigHashInput @@ -658,7 +657,6 @@ type VirtualKeyProviderConfigHashInput struct { Provider string Weight *float64 AllowedModels []string - BudgetID *string RateLimitID *string KeyIDs []string // Only key IDs, not full key objects } @@ -694,10 +692,6 @@ func GenerateVirtualKeyHash(vk tables.TableVirtualKey) (string, error) { if vk.CustomerID != nil { hash.Write([]byte("customerID:" + *vk.CustomerID)) } - // Hash BudgetID - if vk.BudgetID != nil { - hash.Write([]byte("budgetID:" + *vk.BudgetID)) - } // Hash RateLimitID if vk.RateLimitID != nil { hash.Write([]byte("rateLimitID:" + *vk.RateLimitID)) @@ -711,16 +705,6 @@ func GenerateVirtualKeyHash(vk tables.TableVirtualKey) (string, error) { if sortedProviderConfigs[i].Provider != sortedProviderConfigs[j].Provider { return sortedProviderConfigs[i].Provider < sortedProviderConfigs[j].Provider } - bi, bj := "", "" - if sortedProviderConfigs[i].BudgetID != nil { - bi = *sortedProviderConfigs[i].BudgetID - } - if sortedProviderConfigs[j].BudgetID != nil { - bj = *sortedProviderConfigs[j].BudgetID - } - if bi != bj { - return bi < bj - } ri, rj := "", "" if sortedProviderConfigs[i].RateLimitID != nil { ri = *sortedProviderConfigs[i].RateLimitID @@ -758,7 +742,6 @@ func GenerateVirtualKeyHash(vk tables.TableVirtualKey) (string, error) { Provider: pc.Provider, Weight: pc.Weight, AllowedModels: sortedAllowedModels, - BudgetID: pc.BudgetID, RateLimitID: pc.RateLimitID, KeyIDs: keyIDs, } diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index d9592fdd3e..995cad189d 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -364,6 +364,9 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationAddOllamaSGLConfigColumns(ctx, db); err != nil { return err } + if err := migrationAddMultiBudgetTables(ctx, db); err != nil { + return err + } return nil } @@ -939,8 +942,8 @@ func migrationCleanupMCPClientToolsConfig(ctx context.Context, db *gorm.DB) erro // Step 2: Update empty ToolsToExecuteJSON arrays to wildcard ["*"] // Convert "[]" (empty array) to "[\"*\"]" (wildcard array) for backward compatibility updateSQL := ` - UPDATE config_mcp_clients - SET tools_to_execute_json = '["*"]' + UPDATE config_mcp_clients + SET tools_to_execute_json = '["*"]' WHERE tools_to_execute_json = '[]' OR tools_to_execute_json = '' OR tools_to_execute_json IS NULL ` if err := tx.Exec(updateSQL).Error; err != nil { @@ -955,8 +958,8 @@ func migrationCleanupMCPClientToolsConfig(ctx context.Context, db *gorm.DB) erro tx = tx.WithContext(ctx) revertSQL := ` - UPDATE config_mcp_clients - SET tools_to_execute_json = '[]' + UPDATE config_mcp_clients + SET tools_to_execute_json = '[]' WHERE tools_to_execute_json = '["*"]' ` if err := tx.Exec(revertSQL).Error; err != nil { @@ -1011,12 +1014,13 @@ func migrationAddProviderConfigBudgetRateLimit(ctx context.Context, db *gorm.DB) tx = tx.WithContext(ctx) migrator := tx.Migrator() - // Add BudgetID column if it doesn't exist + // Add budget_id and rate_limit_id columns if they don't exist + // Note: budget_id is added via raw SQL because the field was later removed from the struct + // (migrated to governance_budgets.provider_config_id in add_multi_budget_tables) if migrator.HasTable(&tables.TableVirtualKeyProviderConfig{}) { - if !migrator.HasColumn(&tables.TableVirtualKeyProviderConfig{}, "budget_id") { - if err := migrator.AddColumn(&tables.TableVirtualKeyProviderConfig{}, "budget_id"); err != nil { - return fmt.Errorf("failed to add budget_id column: %w", err) - } + if err := tx.Exec("ALTER TABLE governance_virtual_key_provider_configs ADD COLUMN IF NOT EXISTS budget_id VARCHAR(255)").Error; err != nil { + // Ignore error for databases that don't support IF NOT EXISTS (e.g., SQLite) + // The column may already exist from a previous run } // Add RateLimitID column if it doesn't exist @@ -1027,10 +1031,8 @@ func migrationAddProviderConfigBudgetRateLimit(ctx context.Context, db *gorm.DB) } // Create foreign key indexes for better performance - if !migrator.HasIndex(&tables.TableVirtualKeyProviderConfig{}, "idx_provider_config_budget") { - if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_provider_config_budget ON governance_virtual_key_provider_configs (budget_id)").Error; err != nil { - return fmt.Errorf("failed to create budget_id index: %w", err) - } + if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_provider_config_budget ON governance_virtual_key_provider_configs (budget_id)").Error; err != nil { + // Ignore - index may already exist or column may not exist yet } if !migrator.HasIndex(&tables.TableVirtualKeyProviderConfig{}, "idx_provider_config_rate_limit") { @@ -1039,12 +1041,7 @@ func migrationAddProviderConfigBudgetRateLimit(ctx context.Context, db *gorm.DB) } } - // Create FK constraints (dialect‑agnostic) - if !migrator.HasConstraint(&tables.TableVirtualKeyProviderConfig{}, "Budget") { - if err := migrator.CreateConstraint(&tables.TableVirtualKeyProviderConfig{}, "Budget"); err != nil { - return fmt.Errorf("failed to create Budget FK constraint: %w", err) - } - } + // Create FK constraint for RateLimit (Budget FK is no longer needed - budgets use direct FK on budget table) if !migrator.HasConstraint(&tables.TableVirtualKeyProviderConfig{}, "RateLimit") { if err := migrator.CreateConstraint(&tables.TableVirtualKeyProviderConfig{}, "RateLimit"); err != nil { return fmt.Errorf("failed to create RateLimit FK constraint: %w", err) @@ -1058,32 +1055,19 @@ func migrationAddProviderConfigBudgetRateLimit(ctx context.Context, db *gorm.DB) tx = tx.WithContext(ctx) migrator := tx.Migrator() - // Drop indexes first - if err := tx.Exec("DROP INDEX IF EXISTS idx_provider_config_budget").Error; err != nil { - return fmt.Errorf("failed to drop budget_id index: %w", err) - } - if err := tx.Exec("DROP INDEX IF EXISTS idx_provider_config_rate_limit").Error; err != nil { - return fmt.Errorf("failed to drop rate_limit_id index: %w", err) - } + // Drop indexes + _ = tx.Exec("DROP INDEX IF EXISTS idx_provider_config_budget") + _ = tx.Exec("DROP INDEX IF EXISTS idx_provider_config_rate_limit") // Drop FK constraints - if migrator.HasConstraint(&tables.TableVirtualKeyProviderConfig{}, "Budget") { - if err := migrator.DropConstraint(&tables.TableVirtualKeyProviderConfig{}, "Budget"); err != nil { - return fmt.Errorf("failed to drop Budget FK constraint: %w", err) - } - } if migrator.HasConstraint(&tables.TableVirtualKeyProviderConfig{}, "RateLimit") { if err := migrator.DropConstraint(&tables.TableVirtualKeyProviderConfig{}, "RateLimit"); err != nil { return fmt.Errorf("failed to drop RateLimit FK constraint: %w", err) } } - // Drop columns - if migrator.HasColumn(&tables.TableVirtualKeyProviderConfig{}, "budget_id") { - if err := migrator.DropColumn(&tables.TableVirtualKeyProviderConfig{}, "budget_id"); err != nil { - return fmt.Errorf("failed to drop budget_id column: %w", err) - } - } + // Drop columns via raw SQL (budget_id no longer on struct) + _ = tx.Exec("ALTER TABLE governance_virtual_key_provider_configs DROP COLUMN IF EXISTS budget_id") if migrator.HasColumn(&tables.TableVirtualKeyProviderConfig{}, "rate_limit_id") { if err := migrator.DropColumn(&tables.TableVirtualKeyProviderConfig{}, "rate_limit_id"); err != nil { return fmt.Errorf("failed to drop rate_limit_id column: %w", err) @@ -5338,6 +5322,7 @@ func migrationMakeBasePricingColumnsNullable(ctx context.Context, db *gorm.DB) e return nil } +// migrationAddAllowOnAllVirtualKeysColumn adds the allow_on_all_virtual_keys column to the mcp_client table func migrationAddAllowOnAllVirtualKeysColumn(ctx context.Context, db *gorm.DB) error { m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ ID: "add_allow_on_all_virtual_keys_column", @@ -5561,30 +5546,14 @@ func migrationAddReplicateKeyConfigColumn(ctx context.Context, db *gorm.DB) erro return nil } -// migrationAddBudgetCalendarAlignedColumn adds the calendar_aligned column to the governance_budgets table. +// migrationAddBudgetCalendarAlignedColumn was originally for adding calendar_aligned to governance_budgets. +// Calendar alignment is now a VK-level field (governance_virtual_keys.calendar_aligned) added in migrationAddMultiBudgetTables. +// This migration is kept as a no-op so the migrator doesn't try to re-run it. func migrationAddBudgetCalendarAlignedColumn(ctx context.Context, db *gorm.DB) error { m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ - ID: "add_budget_calendar_aligned_column", - Migrate: func(tx *gorm.DB) error { - tx = tx.WithContext(ctx) - mg := tx.Migrator() - if !mg.HasColumn(&tables.TableBudget{}, "calendar_aligned") { - if err := mg.AddColumn(&tables.TableBudget{}, "calendar_aligned"); err != nil { - return fmt.Errorf("failed to add calendar_aligned column: %w", err) - } - } - return nil - }, - Rollback: func(tx *gorm.DB) error { - tx = tx.WithContext(ctx) - mg := tx.Migrator() - if mg.HasColumn(&tables.TableBudget{}, "calendar_aligned") { - if err := mg.DropColumn(&tables.TableBudget{}, "calendar_aligned"); err != nil { - return fmt.Errorf("failed to drop calendar_aligned column: %w", err) - } - } - return nil - }, + ID: "add_budget_calendar_aligned_column", + Migrate: func(tx *gorm.DB) error { return nil }, + Rollback: func(tx *gorm.DB) error { return nil }, }}) if err := m.Migrate(); err != nil { return fmt.Errorf("error running add_budget_calendar_aligned_column migration: %s", err.Error()) @@ -5815,3 +5784,128 @@ func migrationAddOllamaSGLConfigColumns(ctx context.Context, db *gorm.DB) error } return nil } + +// migrationAddMultiBudgetTables creates junction tables for multi-budget support and backfills existing data. +func migrationAddMultiBudgetTables(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_multi_budget_tables", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mg := tx.Migrator() + + // Add calendar_aligned to governance_virtual_keys (VK-level setting) + if !mg.HasColumn(&tables.TableVirtualKey{}, "calendar_aligned") { + if err := mg.AddColumn(&tables.TableVirtualKey{}, "CalendarAligned"); err != nil { + return fmt.Errorf("failed to add calendar_aligned column to governance_virtual_keys: %w", err) + } + } + + // Add FK columns on governance_budgets for multi-budget ownership + if !mg.HasColumn(&tables.TableBudget{}, "virtual_key_id") { + if err := mg.AddColumn(&tables.TableBudget{}, "VirtualKeyID"); err != nil { + return fmt.Errorf("failed to add virtual_key_id column to governance_budgets: %w", err) + } + } + if !mg.HasColumn(&tables.TableBudget{}, "provider_config_id") { + if err := mg.AddColumn(&tables.TableBudget{}, "ProviderConfigID"); err != nil { + return fmt.Errorf("failed to add provider_config_id column to governance_budgets: %w", err) + } + } + + // Create indexes on the new FK columns (AddColumn doesn't create indexes from struct tags) + if !mg.HasIndex(&tables.TableBudget{}, "idx_governance_budgets_virtual_key_id") { + if err := mg.CreateIndex(&tables.TableBudget{}, "VirtualKeyID"); err != nil { + return fmt.Errorf("failed to create index on governance_budgets.virtual_key_id: %w", err) + } + } + if !mg.HasIndex(&tables.TableBudget{}, "idx_governance_budgets_provider_config_id") { + if err := mg.CreateIndex(&tables.TableBudget{}, "ProviderConfigID"); err != nil { + return fmt.Errorf("failed to create index on governance_budgets.provider_config_id: %w", err) + } + } + + // Create FK constraints with CASCADE delete (defined on parent structs) + if !mg.HasConstraint(&tables.TableVirtualKey{}, "Budgets") { + if err := mg.CreateConstraint(&tables.TableVirtualKey{}, "Budgets"); err != nil { + return fmt.Errorf("failed to create FK constraint for VirtualKey -> Budgets: %w", err) + } + } + if !mg.HasConstraint(&tables.TableVirtualKeyProviderConfig{}, "Budgets") { + if err := mg.CreateConstraint(&tables.TableVirtualKeyProviderConfig{}, "Budgets"); err != nil { + return fmt.Errorf("failed to create FK constraint for ProviderConfig -> Budgets: %w", err) + } + } + + // Backfill: set virtual_key_id from legacy VK budget_id (if column still exists) + if mg.HasColumn(&tables.TableVirtualKey{}, "budget_id") { + if err := tx.Exec(` + UPDATE governance_budgets SET virtual_key_id = ( + SELECT id FROM governance_virtual_keys + WHERE governance_virtual_keys.budget_id = governance_budgets.id + ) WHERE virtual_key_id IS NULL AND EXISTS ( + SELECT 1 FROM governance_virtual_keys + WHERE governance_virtual_keys.budget_id = governance_budgets.id + ) + `).Error; err != nil { + return fmt.Errorf("failed to backfill VK budget virtual_key_id: %w", err) + } + } + + // Backfill: set provider_config_id from legacy PC budget_id (if column still exists) + if mg.HasColumn(&tables.TableVirtualKeyProviderConfig{}, "budget_id") { + if err := tx.Exec(` + UPDATE governance_budgets SET provider_config_id = ( + SELECT id FROM governance_virtual_key_provider_configs + WHERE governance_virtual_key_provider_configs.budget_id = governance_budgets.id + ) WHERE provider_config_id IS NULL AND EXISTS ( + SELECT 1 FROM governance_virtual_key_provider_configs + WHERE governance_virtual_key_provider_configs.budget_id = governance_budgets.id + ) + `).Error; err != nil { + return fmt.Errorf("failed to backfill PC budget provider_config_id: %w", err) + } + } + + // Backfill: copy calendar_aligned from legacy budget column to VK-level field + // (governance_budgets.calendar_aligned was added by add_budget_calendar_aligned_column on main) + if mg.HasColumn(&tables.TableBudget{}, "calendar_aligned") { + if err := tx.Exec(` + UPDATE governance_virtual_keys SET calendar_aligned = true + WHERE id IN ( + SELECT DISTINCT virtual_key_id FROM governance_budgets + WHERE calendar_aligned = true AND virtual_key_id IS NOT NULL + ) AND calendar_aligned = false + `).Error; err != nil { + return fmt.Errorf("failed to backfill calendar_aligned from budgets to virtual keys: %w", err) + } + // Drop the legacy calendar_aligned column from governance_budgets + _ = tx.Exec("ALTER TABLE governance_budgets DROP COLUMN IF EXISTS calendar_aligned") + } + + // Drop legacy budget_id columns from VK and ProviderConfig (raw SQL to avoid GORM FK lookup issues) + _ = tx.Exec("ALTER TABLE governance_virtual_keys DROP COLUMN IF EXISTS budget_id") + _ = tx.Exec("ALTER TABLE governance_virtual_key_provider_configs DROP COLUMN IF EXISTS budget_id") + + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mg := tx.Migrator() + if mg.HasColumn(&tables.TableBudget{}, "virtual_key_id") { + if err := mg.DropColumn(&tables.TableBudget{}, "virtual_key_id"); err != nil { + return err + } + } + if mg.HasColumn(&tables.TableBudget{}, "provider_config_id") { + if err := mg.DropColumn(&tables.TableBudget{}, "provider_config_id"); err != nil { + return err + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error running add_multi_budget_tables migration: %s", err.Error()) + } + return nil +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index ade838add8..f3b5d45b68 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -35,6 +35,7 @@ func getWeight(w *float64) float64 { return *w } +// schemaKeyFromTableKey converts a database key to a schema key. func schemaKeyFromTableKey(dbKey tables.TableKey) schemas.Key { return schemas.Key{ ID: dbKey.KeyID, @@ -59,6 +60,7 @@ func schemaKeyFromTableKey(dbKey tables.TableKey) schemas.Key { } } +// tableKeyFromSchemaKey converts a schema key to a database key. func tableKeyFromSchemaKey(provider tables.TableProvider, key schemas.Key) (tables.TableKey, error) { dbKey := tables.TableKey{ Provider: provider.Name, @@ -175,13 +177,10 @@ func (s *RDBConfigStore) parseGormError(err error) error { if err == nil { return nil } - if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNotFound } - errMsg := err.Error() - // Check for unique constraint violations // SQLite format: "UNIQUE constraint failed: table_name.column_name" // PostgreSQL format: "ERROR: duplicate key value violates unique constraint" @@ -914,7 +913,6 @@ func (s *RDBConfigStore) CreateProviderKey(ctx context.Context, provider schemas } else { txDB = s.db } - var dbProvider tables.TableProvider if err := txDB.WithContext(ctx).Where("name = ?", string(provider)).First(&dbProvider).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -922,16 +920,13 @@ func (s *RDBConfigStore) CreateProviderKey(ctx context.Context, provider schemas } return err } - dbKey, err := tableKeyFromSchemaKey(dbProvider, key) if err != nil { return err } - if err := txDB.WithContext(ctx).Create(&dbKey).Error; err != nil { return s.parseGormError(err) } - return nil } @@ -1787,35 +1782,31 @@ func (s *RDBConfigStore) UpdatePlugin(ctx context.Context, plugin *tables.TableP txDB = s.db.Begin() localTx = true } - // Mark plugin as custom if path is not empty if plugin.Path != nil && strings.TrimSpace(*plugin.Path) != "" { plugin.IsCustom = true } else { plugin.IsCustom = false } - if err := txDB.WithContext(ctx).Delete(&tables.TablePlugin{}, "name = ?", plugin.Name).Error; err != nil { if localTx { txDB.Rollback() } return err } - if err := txDB.WithContext(ctx).Create(plugin).Error; err != nil { if localTx { txDB.Rollback() } return s.parseGormError(err) } - if localTx { return txDB.Commit().Error } - return nil } +// DeletePlugin deletes a plugin from the database. func (s *RDBConfigStore) DeletePlugin(ctx context.Context, name string, tx ...*gorm.DB) error { var txDB *gorm.DB if len(tx) > 0 { @@ -1828,6 +1819,7 @@ func (s *RDBConfigStore) DeletePlugin(ctx context.Context, name string, tx ...*g // GOVERNANCE METHODS +// GetRedactedVirtualKeys retrieves redacted virtual keys from the database. func (s *RDBConfigStore) GetRedactedVirtualKeys(ctx context.Context, ids []string) ([]tables.TableVirtualKey, error) { var virtualKeys []tables.TableVirtualKey @@ -1845,6 +1837,7 @@ func (s *RDBConfigStore) GetRedactedVirtualKeys(ctx context.Context, ids []strin return virtualKeys, nil } +// preloadCustomerRelations preloads the customer relations for a virtual key. func preloadCustomerRelations(db *gorm.DB, prefix string) *gorm.DB { relation := func(name string) string { if prefix == "" { @@ -1852,7 +1845,6 @@ func preloadCustomerRelations(db *gorm.DB, prefix string) *gorm.DB { } return prefix + name } - return db. Preload(relation("Teams")). Preload(relation("Budget")). @@ -1860,16 +1852,16 @@ func preloadCustomerRelations(db *gorm.DB, prefix string) *gorm.DB { Preload(relation("VirtualKeys")) } +// preloadVirtualKeyBaseRelations preloads the base relationships for a virtual key. func preloadVirtualKeyBaseRelations(db *gorm.DB) *gorm.DB { - db = db.Preload("Team").Preload("Team.Customer") - - db = db.Preload("Customer") - return db. - Preload("Budget"). + Preload("Team"). + Preload("Team.Customer"). + Preload("Customer"). + Preload("Budgets"). Preload("RateLimit"). Preload("ProviderConfigs"). - Preload("ProviderConfigs.Budget"). + Preload("ProviderConfigs.Budgets"). Preload("ProviderConfigs.RateLimit"). Preload("ProviderConfigs.Keys", func(db *gorm.DB) *gorm.DB { return db.Select("id, name, key_id, models_json, provider") @@ -1878,6 +1870,7 @@ func preloadVirtualKeyBaseRelations(db *gorm.DB) *gorm.DB { Preload("MCPConfigs.MCPClient") } +// preloadVirtualKeyDetailRelations preloads the detail relationships for a virtual key. func preloadVirtualKeyDetailRelations(db *gorm.DB) *gorm.DB { return preloadCustomerRelations(preloadVirtualKeyBaseRelations(db), "Customer.") } @@ -1964,7 +1957,6 @@ func (s *RDBConfigStore) GetVirtualKeyByValue(ctx context.Context, value string) valueHash := encrypt.HashSHA256(value) var virtualKey tables.TableVirtualKey query := preloadVirtualKeyBaseRelations(s.db.WithContext(ctx)) - // Use hash-based lookup if hash column is populated, fall back to plaintext for backward compat if err := query.Where("value_hash = ?", valueHash).First(&virtualKey).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -1982,6 +1974,7 @@ func (s *RDBConfigStore) GetVirtualKeyByValue(ctx context.Context, value string) return &virtualKey, nil } +// CreateVirtualKey creates a new virtual key in the database. func (s *RDBConfigStore) CreateVirtualKey(ctx context.Context, virtualKey *tables.TableVirtualKey, tx ...*gorm.DB) error { var txDB *gorm.DB if len(tx) > 0 { @@ -1995,6 +1988,7 @@ func (s *RDBConfigStore) CreateVirtualKey(ctx context.Context, virtualKey *table return nil } +// UpdateVirtualKey updates an existing virtual key in the database. func (s *RDBConfigStore) UpdateVirtualKey(ctx context.Context, virtualKey *tables.TableVirtualKey, tx ...*gorm.DB) error { var txDB *gorm.DB if len(tx) > 0 { @@ -2095,33 +2089,26 @@ func (s *RDBConfigStore) DeleteVirtualKey(ctx context.Context, id string) error return err } - // Collect budget and rate limit IDs from provider configs before deletion - var providerConfigBudgetIDs []string + // Delete provider config resources before deleting the configs themselves var providerConfigRateLimitIDs []string for _, pc := range virtualKey.ProviderConfigs { // Delete the keys join table entries if err := tx.WithContext(ctx).Exec("DELETE FROM governance_virtual_key_provider_config_keys WHERE table_virtual_key_provider_config_id = ?", pc.ID).Error; err != nil { return err } - // Collect budget and rate limit IDs for deletion after provider config - if pc.BudgetID != nil { - providerConfigBudgetIDs = append(providerConfigBudgetIDs, *pc.BudgetID) + // Delete budgets owned by this provider config + if err := tx.WithContext(ctx).Where("provider_config_id = ?", pc.ID).Delete(&tables.TableBudget{}).Error; err != nil { + return err } if pc.RateLimitID != nil { providerConfigRateLimitIDs = append(providerConfigRateLimitIDs, *pc.RateLimitID) } } - // Delete all provider configs associated with the virtual key first + // Delete all provider configs associated with the virtual key if err := tx.WithContext(ctx).Delete(&tables.TableVirtualKeyProviderConfig{}, "virtual_key_id = ?", id).Error; err != nil { return err } - // Now delete the collected budgets and rate limits - for _, budgetID := range providerConfigBudgetIDs { - if err := tx.WithContext(ctx).Delete(&tables.TableBudget{}, "id = ?", budgetID).Error; err != nil { - return err - } - } for _, rateLimitID := range providerConfigRateLimitIDs { if err := tx.WithContext(ctx).Delete(&tables.TableRateLimit{}, "id = ?", rateLimitID).Error; err != nil { return err @@ -2131,8 +2118,10 @@ func (s *RDBConfigStore) DeleteVirtualKey(ctx context.Context, id string) error if err := tx.WithContext(ctx).Delete(&tables.TableVirtualKeyMCPConfig{}, "virtual_key_id = ?", id).Error; err != nil { return err } - // Delete the budget associated with the virtual key - budgetID := virtualKey.BudgetID + // Delete budgets owned by this virtual key + if err := tx.WithContext(ctx).Where("virtual_key_id = ?", id).Delete(&tables.TableBudget{}).Error; err != nil { + return err + } rateLimitID := virtualKey.RateLimitID // Delete the virtual key if err := tx.WithContext(ctx).Delete(&tables.TableVirtualKey{}, "id = ?", id).Error; err != nil { @@ -2141,11 +2130,6 @@ func (s *RDBConfigStore) DeleteVirtualKey(ctx context.Context, id string) error } return err } - if budgetID != nil { - if err := tx.WithContext(ctx).Delete(&tables.TableBudget{}, "id = ?", *budgetID).Error; err != nil { - return err - } - } // Delete the rate limit associated with the virtual key if rateLimitID != nil { if err := tx.WithContext(ctx).Delete(&tables.TableRateLimit{}, "id = ?", *rateLimitID).Error; err != nil { @@ -2343,18 +2327,15 @@ func (s *RDBConfigStore) DeleteVirtualKeyProviderConfig(ctx context.Context, id } return err } - // Store the budget and rate limit IDs before deleting - budgetID := providerConfig.BudgetID + // Store the rate limit ID before deleting rateLimitID := providerConfig.RateLimitID - // Delete the provider config first - if err := txDB.WithContext(ctx).Delete(&tables.TableVirtualKeyProviderConfig{}, "id = ?", id).Error; err != nil { + // Delete budgets owned by this provider config + if err := txDB.WithContext(ctx).Where("provider_config_id = ?", id).Delete(&tables.TableBudget{}).Error; err != nil { return err } - // Delete the budget if it exists - if budgetID != nil { - if err := txDB.WithContext(ctx).Delete(&tables.TableBudget{}, "id = ?", *budgetID).Error; err != nil { - return err - } + // Delete the provider config + if err := txDB.WithContext(ctx).Delete(&tables.TableVirtualKeyProviderConfig{}, "id = ?", id).Error; err != nil { + return err } // Delete the rate limit if it exists if rateLimitID != nil { @@ -3178,6 +3159,7 @@ func (s *RDBConfigStore) GetModelConfigs(ctx context.Context) ([]tables.TableMod return modelConfigs, nil } +// GetModelConfigsPaginated retrieves model configs with pagination, filtering, and search support. func (s *RDBConfigStore) GetModelConfigsPaginated(ctx context.Context, params ModelConfigsQueryParams) ([]tables.TableModelConfig, int64, error) { baseQuery := s.db.WithContext(ctx).Model(&tables.TableModelConfig{}) diff --git a/framework/configstore/rdb_test.go b/framework/configstore/rdb_test.go index 796539a3a9..406e7a5cfd 100644 --- a/framework/configstore/rdb_test.go +++ b/framework/configstore/rdb_test.go @@ -520,24 +520,28 @@ func TestCreateVirtualKey_WithBudgetAndRateLimit(t *testing.T) { require.NoError(t, err) // Create virtual key with references - budgetID := "budget-for-vk" rateLimitID := "rate-limit-for-vk" + vkID := "vk-with-refs" vk := &tables.TableVirtualKey{ - ID: "vk-with-refs", + ID: vkID, Name: "VK With References", Value: "vk-refs-value", IsActive: true, - BudgetID: &budgetID, RateLimitID: &rateLimitID, } err = store.CreateVirtualKey(ctx, vk) require.NoError(t, err) + // Link the existing budget to the VK via FK + budget.VirtualKeyID = &vkID + err = store.UpdateBudget(ctx, budget) + require.NoError(t, err) + result, err := store.GetVirtualKey(ctx, "vk-with-refs") require.NoError(t, err) - assert.NotNil(t, result.BudgetID) - assert.Equal(t, "budget-for-vk", *result.BudgetID) + assert.Len(t, result.Budgets, 1) + assert.Equal(t, "budget-for-vk", result.Budgets[0].ID) assert.NotNil(t, result.RateLimitID) assert.Equal(t, "rate-limit-for-vk", *result.RateLimitID) } @@ -999,19 +1003,23 @@ func TestFullVirtualKeyFlow(t *testing.T) { require.NoError(t, err) // Step 4: Create virtual key - budgetID := "integration-budget" rateLimitID := "integration-rate-limit" + integrationVKID := "integration-vk" vk := &tables.TableVirtualKey{ - ID: "integration-vk", + ID: integrationVKID, Name: "Integration Virtual Key", Value: "vk-integration-xyz", IsActive: true, - BudgetID: &budgetID, RateLimitID: &rateLimitID, } err = store.CreateVirtualKey(ctx, vk) require.NoError(t, err) + // Link the existing budget to the VK via FK + budget.VirtualKeyID = &integrationVKID + err = store.UpdateBudget(ctx, budget) + require.NoError(t, err) + // Step 5: Create provider config with key reference weight := 1.0 pc := &tables.TableVirtualKeyProviderConfig{ @@ -1029,7 +1037,7 @@ func TestFullVirtualKeyFlow(t *testing.T) { result, err := store.GetVirtualKey(ctx, "integration-vk") require.NoError(t, err) assert.Equal(t, "Integration Virtual Key", result.Name) - assert.NotNil(t, result.BudgetID) + assert.Len(t, result.Budgets, 1) assert.NotNil(t, result.RateLimitID) configs, err := store.GetVirtualKeyProviderConfigs(ctx, "integration-vk") diff --git a/framework/configstore/tables/budget.go b/framework/configstore/tables/budget.go index e35c530f3d..2d7d397d26 100644 --- a/framework/configstore/tables/budget.go +++ b/framework/configstore/tables/budget.go @@ -15,9 +15,9 @@ type TableBudget struct { LastReset time.Time `gorm:"index" json:"last_reset"` // Last time budget was reset CurrentUsage float64 `gorm:"default:0" json:"current_usage"` // Current usage in dollars - // CalendarAligned snaps LastReset to the start of the current calendar period (day, week, month, year) - // instead of the exact creation/update time, so budgets reset at clean calendar boundaries. - CalendarAligned bool `gorm:"default:false" json:"calendar_aligned"` + // Owner FKs: a budget belongs to at most one VK or one ProviderConfig + VirtualKeyID *string `gorm:"type:varchar(255);index" json:"virtual_key_id,omitempty"` + ProviderConfigID *uint `gorm:"index" json:"provider_config_id,omitempty"` // Config hash is used to detect the changes synced from config.json file // Every time we sync the config.json file, we will update the config hash @@ -32,6 +32,11 @@ func (TableBudget) TableName() string { return "governance_budgets" } // BeforeSave hook for Budget to validate reset duration format and max limit func (b *TableBudget) BeforeSave(tx *gorm.DB) error { + // A budget belongs to at most one owner type + if b.VirtualKeyID != nil && b.ProviderConfigID != nil { + return fmt.Errorf("budget cannot belong to both a virtual key and a provider config") + } + // Validate that ResetDuration is in correct format (e.g., "30s", "5m", "1h", "1d", "1w", "1M", "1Y") if d, err := ParseDuration(b.ResetDuration); err != nil { return fmt.Errorf("invalid reset duration format: %s", b.ResetDuration) diff --git a/framework/configstore/tables/virtualkey.go b/framework/configstore/tables/virtualkey.go index d7b1c92a6a..8c70d2e4bf 100644 --- a/framework/configstore/tables/virtualkey.go +++ b/framework/configstore/tables/virtualkey.go @@ -30,13 +30,12 @@ type TableVirtualKeyProviderConfig struct { Weight *float64 `json:"weight"` AllowedModels schemas.WhiteList `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"` + RateLimitID *string `gorm:"type:varchar(255);index" json:"rate_limit_id,omitempty"` // Relationships - Budget *TableBudget `gorm:"foreignKey:BudgetID;onDelete:CASCADE" json:"budget,omitempty"` RateLimit *TableRateLimit `gorm:"foreignKey:RateLimitID;onDelete:CASCADE" json:"rate_limit,omitempty"` - Keys []TableKey `gorm:"many2many:governance_virtual_key_provider_config_keys;constraint:OnDelete:CASCADE" json:"keys"` // Used when AllowAllKeys is false; empty means no keys allowed + Budgets []TableBudget `gorm:"foreignKey:ProviderConfigID;constraint:OnDelete:CASCADE" json:"budgets,omitempty"` // Multiple budgets with different reset intervals + Keys []TableKey `gorm:"many2many:governance_virtual_key_provider_config_keys;constraint:OnDelete:CASCADE" json:"keys"` // Empty means all keys allowed for this provider } // TableName sets the table name for each model @@ -211,16 +210,16 @@ type TableVirtualKey struct { MCPConfigs []TableVirtualKeyMCPConfig `gorm:"foreignKey:VirtualKeyID;constraint:OnDelete:CASCADE" json:"mcp_configs"` // Foreign key relationships (mutually exclusive: either TeamID or CustomerID, not both) - TeamID *string `gorm:"type:varchar(255);index" json:"team_id,omitempty"` - CustomerID *string `gorm:"type:varchar(255);index" json:"customer_id,omitempty"` - BudgetID *string `gorm:"type:varchar(255);index" json:"budget_id,omitempty"` - RateLimitID *string `gorm:"type:varchar(255);index" json:"rate_limit_id,omitempty"` + TeamID *string `gorm:"type:varchar(255);index" json:"team_id,omitempty"` + CustomerID *string `gorm:"type:varchar(255);index" json:"customer_id,omitempty"` + RateLimitID *string `gorm:"type:varchar(255);index" json:"rate_limit_id,omitempty"` + CalendarAligned bool `gorm:"default:false" json:"calendar_aligned"` // When true, all budgets under this VK reset at clean calendar boundaries // Relationships Team *TableTeam `gorm:"foreignKey:TeamID" json:"team,omitempty"` Customer *TableCustomer `gorm:"foreignKey:CustomerID" json:"customer,omitempty"` - Budget *TableBudget `gorm:"foreignKey:BudgetID;onDelete:CASCADE" json:"budget,omitempty"` RateLimit *TableRateLimit `gorm:"foreignKey:RateLimitID;onDelete:CASCADE" json:"rate_limit,omitempty"` + Budgets []TableBudget `gorm:"foreignKey:VirtualKeyID;constraint:OnDelete:CASCADE" json:"budgets,omitempty"` // Multiple budgets with different reset intervals // Config hash is used to detect the changes synced from config.json file // Every time we sync the config.json file, we will update the config hash diff --git a/plugins/governance/model_provider_governance_test.go b/plugins/governance/modelprovidergovernance_test.go similarity index 99% rename from plugins/governance/model_provider_governance_test.go rename to plugins/governance/modelprovidergovernance_test.go index e363c15435..a7889f14bd 100644 --- a/plugins/governance/model_provider_governance_test.go +++ b/plugins/governance/modelprovidergovernance_test.go @@ -1479,9 +1479,10 @@ func TestPreLLMHook_ModelProviderPass_VirtualKeyChecksPass(t *testing.T) { logger := NewMockLogger() // Model/provider checks pass (no limits) // Virtual key checks also pass - vk := buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test VK", []configstoreTables.TableVirtualKeyProviderConfig{ + vk := buildVirtualKey("vk1", "sk-bf-test", "Test VK", true) + vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{ buildProviderConfig("openai", []string{"*"}), - }) + } store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ VirtualKeys: []configstoreTables.TableVirtualKey{*vk}, }, nil) diff --git a/plugins/governance/resolver.go b/plugins/governance/resolver.go index 65607ed86e..bf184908d3 100644 --- a/plugins/governance/resolver.go +++ b/plugins/governance/resolver.go @@ -353,7 +353,7 @@ func (r *BudgetResolver) isProviderBudgetViolated(ctx context.Context, vk *confi } // 2. Check VK-level provider config budget - if config.Budget == nil { + if len(config.Budgets) == 0 { return false } if err := r.store.CheckBudget(ctx, vk, request, nil); err != nil { diff --git a/plugins/governance/resolver_test.go b/plugins/governance/resolver_test.go index 4d06c29a82..fceb69a737 100644 --- a/plugins/governance/resolver_test.go +++ b/plugins/governance/resolver_test.go @@ -16,9 +16,10 @@ import ( // TestBudgetResolver_EvaluateRequest_AllowedRequest tests happy path func TestBudgetResolver_EvaluateRequest_AllowedRequest(t *testing.T) { logger := NewMockLogger() - vk := buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test VK", []configstoreTables.TableVirtualKeyProviderConfig{ + vk := buildVirtualKey("vk1", "sk-bf-test", "Test VK", true) + vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{ buildProviderConfig("openai", []string{"*"}), - }) + } store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ VirtualKeys: []configstoreTables.TableVirtualKey{*vk}, @@ -102,7 +103,6 @@ func TestBudgetResolver_EvaluateRequest_ModelBlocked(t *testing.T) { AllowedModels: []string{"gpt-4", "gpt-4-turbo"}, // Only these models Weight: bifrost.Ptr(1.0), RateLimit: nil, - Budget: nil, Keys: []configstoreTables.TableKey{}, }, } @@ -469,9 +469,10 @@ func TestBudgetResolver_IsModelAllowed(t *testing.T) { // TestBudgetResolver_ContextPopulation tests context values are set correctly func TestBudgetResolver_ContextPopulation(t *testing.T) { logger := NewMockLogger() - vk := buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test VK", []configstoreTables.TableVirtualKeyProviderConfig{ + vk := buildVirtualKey("vk1", "sk-bf-test", "Test VK", true) + vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{ buildProviderConfig("openai", []string{"*"}), - }) + } customer := buildCustomer("cust1", "Customer 1", nil) team := buildTeam("team1", "Team 1", nil) team.CustomerID = &customer.ID diff --git a/plugins/governance/store.go b/plugins/governance/store.go index 6158cc1302..49086dc9e4 100644 --- a/plugins/governance/store.go +++ b/plugins/governance/store.go @@ -195,12 +195,19 @@ func (gs *LocalGovernanceStore) GetGovernanceData() *GovernanceData { if vk == nil { return } - if vk.BudgetID != nil { - if liveBudget, exists := gs.budgets.Load(*vk.BudgetID); exists && liveBudget != nil { - if b, ok := liveBudget.(*configstoreTables.TableBudget); ok { - vk.Budget = b + // Cross-reference live budget/rate limit from standalone maps + // (usage updates clone into budgets/rateLimits maps, so embedded pointers go stale) + // Hydrate multi-budgets from live sync.Map + if len(vk.Budgets) > 0 { + liveBudgets := make([]configstoreTables.TableBudget, 0, len(vk.Budgets)) + for _, b := range vk.Budgets { + if lb, exists := gs.budgets.Load(b.ID); exists && lb != nil { + if budget, ok := lb.(*configstoreTables.TableBudget); ok { + liveBudgets = append(liveBudgets, *budget) + } } } + vk.Budgets = liveBudgets } if vk.RateLimitID != nil { if liveRL, exists := gs.rateLimits.Load(*vk.RateLimitID); exists && liveRL != nil { @@ -213,12 +220,17 @@ func (gs *LocalGovernanceStore) GetGovernanceData() *GovernanceData { configs := make([]configstoreTables.TableVirtualKeyProviderConfig, len(vk.ProviderConfigs)) copy(configs, vk.ProviderConfigs) for i := range configs { - if configs[i].BudgetID != nil { - if liveBudget, exists := gs.budgets.Load(*configs[i].BudgetID); exists && liveBudget != nil { - if b, ok := liveBudget.(*configstoreTables.TableBudget); ok { - configs[i].Budget = b + // Hydrate provider config multi-budgets + if len(configs[i].Budgets) > 0 { + liveBudgets := make([]configstoreTables.TableBudget, 0, len(configs[i].Budgets)) + for _, b := range configs[i].Budgets { + if lb, exists := gs.budgets.Load(b.ID); exists && lb != nil { + if budget, ok := lb.(*configstoreTables.TableBudget); ok { + liveBudgets = append(liveBudgets, *budget) + } } } + configs[i].Budgets = liveBudgets } if configs[i].RateLimitID != nil { if liveRL, exists := gs.rateLimits.Load(*configs[i].RateLimitID); exists && liveRL != nil { @@ -1527,10 +1539,34 @@ func (gs *LocalGovernanceStore) ResetExpiredBudgetsInMemory(ctx context.Context) var shouldReset bool var newLastReset time.Time - if budget.CalendarAligned { + // Check if the owning VK has calendar alignment enabled + // virtualKeys map is keyed by VK value (not ID), so we scan to find by VirtualKeyID + calendarAligned := false + if budget.VirtualKeyID != nil { + gs.virtualKeys.Range(func(_, v interface{}) bool { + if vk, ok := v.(*configstoreTables.TableVirtualKey); ok && vk != nil && vk.ID == *budget.VirtualKeyID { + calendarAligned = vk.CalendarAligned + return false // stop + } + return true + }) + } else if budget.ProviderConfigID != nil { + // Provider config budgets: look up the VK that owns this provider config + gs.virtualKeys.Range(func(_, v interface{}) bool { + if vk, ok := v.(*configstoreTables.TableVirtualKey); ok && vk != nil { + for _, pc := range vk.ProviderConfigs { + if pc.ID == *budget.ProviderConfigID { + calendarAligned = vk.CalendarAligned + return false // stop + } + } + } + return true + }) + } + + if calendarAligned { // Calendar-aligned: reset when we've entered a genuinely new calendar period. - // This avoids the double-reset bug with rolling durations in months with - // more days than ParseDuration approximates (e.g. 31-day months with "1M" = 30 days). currentPeriodStart := configstoreTables.GetCalendarPeriodStart(budget.ResetDuration, now) if currentPeriodStart.After(budget.LastReset) { shouldReset = true @@ -2102,33 +2138,17 @@ func (gs *LocalGovernanceStore) loadFromConfigMemory(ctx context.Context, config } } - for i := range budgets { - if vk.BudgetID != nil && budgets[i].ID == *vk.BudgetID { - vk.Budget = &budgets[i] - } - } - for i := range rateLimits { if vk.RateLimitID != nil && rateLimits[i].ID == *vk.RateLimitID { vk.RateLimit = &rateLimits[i] } } - // Populate provider config relationships with budgets and rate limits + // Populate provider config relationships with rate limits if vk.ProviderConfigs != nil { for j := range vk.ProviderConfigs { pc := &vk.ProviderConfigs[j] - // Populate budget - if pc.BudgetID != nil { - for k := range budgets { - if budgets[k].ID == *pc.BudgetID { - pc.Budget = &budgets[k] - break - } - } - } - // Populate rate limit if pc.RateLimitID != nil { for k := range rateLimits { @@ -2373,22 +2393,36 @@ func (gs *LocalGovernanceStore) collectBudgetsFromHierarchy(vk *configstoreTable var budgetNames []string // Collect all budgets in hierarchy order using lock-free sync.Map access (Provider Configs → VK → Team → Customer) + seen := make(map[string]bool) for _, pc := range vk.ProviderConfigs { - if pc.BudgetID != nil && pc.Provider == string(requestedProvider) { - if budgetValue, exists := gs.budgets.Load(*pc.BudgetID); exists && budgetValue != nil { + if pc.Provider != string(requestedProvider) { + continue + } + // Multi-budgets + for _, b := range pc.Budgets { + if seen[b.ID] { + continue + } + if budgetValue, exists := gs.budgets.Load(b.ID); exists && budgetValue != nil { if budget, ok := budgetValue.(*configstoreTables.TableBudget); ok && budget != nil { budgets = append(budgets, budget) budgetNames = append(budgetNames, pc.Provider) + seen[budget.ID] = true } } } } - if vk.BudgetID != nil { - if budgetValue, exists := gs.budgets.Load(*vk.BudgetID); exists && budgetValue != nil { + // VK-level multi-budgets + for _, b := range vk.Budgets { + if seen[b.ID] { + continue + } + if budgetValue, exists := gs.budgets.Load(b.ID); exists && budgetValue != nil { if budget, ok := budgetValue.(*configstoreTables.TableBudget); ok && budget != nil { budgets = append(budgets, budget) budgetNames = append(budgetNames, "VK") + seen[budget.ID] = true } } } @@ -2477,9 +2511,9 @@ func (gs *LocalGovernanceStore) CreateVirtualKeyInMemory(vk *configstoreTables.T return // Nothing to create } - // Create associated budget if exists - if vk.Budget != nil { - gs.budgets.Store(vk.Budget.ID, vk.Budget) + // Store budgets + for i := range vk.Budgets { + gs.budgets.Store(vk.Budgets[i].ID, &vk.Budgets[i]) } // Create associated rate limit if exists @@ -2490,8 +2524,8 @@ func (gs *LocalGovernanceStore) CreateVirtualKeyInMemory(vk *configstoreTables.T // Create provider config budgets and rate limits if they exist if vk.ProviderConfigs != nil { for _, pc := range vk.ProviderConfigs { - if pc.Budget != nil { - gs.budgets.Store(pc.Budget.ID, pc.Budget) + for i := range pc.Budgets { + gs.budgets.Store(pc.Budgets[i].ID, &pc.Budgets[i]) } if pc.RateLimit != nil { gs.rateLimits.Store(pc.RateLimit.ID, pc.RateLimit) @@ -2518,23 +2552,37 @@ func (gs *LocalGovernanceStore) UpdateVirtualKeyInMemory(vk *configstoreTables.T // Create clone to avoid modifying the original clone := *vk - // Update Budget for VK in memory store - if clone.Budget != nil { - // Preserve existing usage from memory when updating budget config - // The usage tracker maintains current usage in memory, and we only want to update - // the configuration fields (max_limit, reset_duration) from the database - if existingBudgetValue, exists := gs.budgets.Load(clone.Budget.ID); exists && existingBudgetValue != nil { + + // Collect all incoming budget IDs across VK + provider configs to avoid + // deleting a budget that was moved between VK-level and PC-level in one update. + allNewBudgetIDs := make(map[string]bool) + for i := range clone.Budgets { + allNewBudgetIDs[clone.Budgets[i].ID] = true + } + for i := range clone.ProviderConfigs { + for j := range clone.ProviderConfigs[i].Budgets { + allNewBudgetIDs[clone.ProviderConfigs[i].Budgets[j].ID] = true + } + } + + // Update multi-budgets for VK + for i := range clone.Budgets { + // Preserve existing usage from memory + if existingBudgetValue, exists := gs.budgets.Load(clone.Budgets[i].ID); exists && existingBudgetValue != nil { if existingBudget, ok := existingBudgetValue.(*configstoreTables.TableBudget); ok && existingBudget != nil { - // Preserve current usage and last reset time from existing in-memory budget - clone.Budget.CurrentUsage = existingBudget.CurrentUsage - clone.Budget.LastReset = existingBudget.LastReset + clone.Budgets[i].CurrentUsage = existingBudget.CurrentUsage + clone.Budgets[i].LastReset = existingBudget.LastReset } } - gs.budgets.Store(clone.Budget.ID, clone.Budget) - } else if existingVK.Budget != nil { - // Budget was removed from the virtual key, delete it from memory - gs.budgets.Delete(existingVK.Budget.ID) + gs.budgets.Store(clone.Budgets[i].ID, &clone.Budgets[i]) } + // Delete removed multi-budgets + for _, oldBudget := range existingVK.Budgets { + if !allNewBudgetIDs[oldBudget.ID] { + gs.budgets.Delete(oldBudget.ID) + } + } + if clone.RateLimit != nil { // Preserve existing usage from memory when updating rate limit config // The usage tracker maintains current usage in memory, and we only want to update @@ -2584,22 +2632,23 @@ func (gs *LocalGovernanceStore) UpdateVirtualKeyInMemory(vk *configstoreTables.T clone.ProviderConfigs[i].RateLimit = nil } } - // Update Budget for provider config in memory store - if pc.Budget != nil { - // Preserve existing usage from memory when updating provider config budget - if existingBudgetValue, exists := gs.budgets.Load(pc.Budget.ID); exists && existingBudgetValue != nil { + // Update multi-budgets for provider config + for j := range clone.ProviderConfigs[i].Budgets { + b := &clone.ProviderConfigs[i].Budgets[j] + if existingBudgetValue, exists := gs.budgets.Load(b.ID); exists && existingBudgetValue != nil { if existingBudget, ok := existingBudgetValue.(*configstoreTables.TableBudget); ok && existingBudget != nil { - // Preserve current usage and last reset time from existing in-memory budget - clone.ProviderConfigs[i].Budget.CurrentUsage = existingBudget.CurrentUsage - clone.ProviderConfigs[i].Budget.LastReset = existingBudget.LastReset + b.CurrentUsage = existingBudget.CurrentUsage + b.LastReset = existingBudget.LastReset } } - gs.budgets.Store(clone.ProviderConfigs[i].Budget.ID, clone.ProviderConfigs[i].Budget) - } else { - // Budget was removed from provider config, delete it from memory if it existed - if existingPC, exists := existingProviderConfigs[pc.ID]; exists && existingPC.Budget != nil { - gs.budgets.Delete(existingPC.Budget.ID) - clone.ProviderConfigs[i].Budget = nil + gs.budgets.Store(b.ID, b) + } + // Delete removed multi-budgets for this provider config + if existingPC, exists := existingProviderConfigs[pc.ID]; exists { + for _, oldBudget := range existingPC.Budgets { + if !allNewBudgetIDs[oldBudget.ID] { + gs.budgets.Delete(oldBudget.ID) + } } } } @@ -2625,9 +2674,9 @@ func (gs *LocalGovernanceStore) DeleteVirtualKeyInMemory(vkID string) { } if vk.ID == vkID { - // Delete associated budget if exists - if vk.BudgetID != nil { - gs.budgets.Delete(*vk.BudgetID) + // Delete budgets + for _, b := range vk.Budgets { + gs.budgets.Delete(b.ID) } // Delete associated rate limit if exists @@ -2638,8 +2687,8 @@ func (gs *LocalGovernanceStore) DeleteVirtualKeyInMemory(vkID string) { // Delete provider config budgets and rate limits if vk.ProviderConfigs != nil { for _, pc := range vk.ProviderConfigs { - if pc.BudgetID != nil { - gs.budgets.Delete(*pc.BudgetID) + for _, b := range pc.Budgets { + gs.budgets.Delete(b.ID) } if pc.RateLimitID != nil { gs.rateLimits.Delete(*pc.RateLimitID) @@ -3165,18 +3214,22 @@ func (gs *LocalGovernanceStore) updateBudgetReferences(resetBudget *configstoreT needsUpdate := false clone := *vk - // Check VK-level budget - if vk.BudgetID != nil && *vk.BudgetID == budgetID { - clone.Budget = resetBudget - needsUpdate = true + // Check VK-level budgets + for i, b := range clone.Budgets { + if b.ID == budgetID { + clone.Budgets[i] = *resetBudget + needsUpdate = true + } } // Check provider config budgets if vk.ProviderConfigs != nil { - for i, pc := range clone.ProviderConfigs { - if pc.BudgetID != nil && *pc.BudgetID == budgetID { - clone.ProviderConfigs[i].Budget = resetBudget - needsUpdate = true + for i := range clone.ProviderConfigs { + for j, b := range clone.ProviderConfigs[i].Budgets { + if b.ID == budgetID { + clone.ProviderConfigs[i].Budgets[j] = *resetBudget + needsUpdate = true + } } } } @@ -3638,9 +3691,9 @@ func (gs *LocalGovernanceStore) GetBudgetAndRateLimitStatus(ctx context.Context, } } } - // Get budget status - if pc.BudgetID != nil { - if budgetValue, ok := gs.budgets.Load(*pc.BudgetID); ok && budgetValue != nil { + // Get budget status from multi-budgets + for _, b := range pc.Budgets { + if budgetValue, ok := gs.budgets.Load(b.ID); ok && budgetValue != nil { if budget, ok := budgetValue.(*configstoreTables.TableBudget); ok && budget != nil { baseline, exists := budgetBaselines[budget.ID] if !exists { diff --git a/plugins/governance/store_test.go b/plugins/governance/store_test.go index 0640052d56..21a54e1bd5 100644 --- a/plugins/governance/store_test.go +++ b/plugins/governance/store_test.go @@ -198,16 +198,397 @@ func TestGovernanceStore_CheckBudget_HierarchyValidation(t *testing.T) { // Test: If VK budget exceeds limit, should fail // Update the budget directly in the budgets map (since UpdateVirtualKeyInMemory preserves usage) - if vk.BudgetID != nil { - if budgetValue, exists := store.budgets.Load(*vk.BudgetID); exists && budgetValue != nil { + if len(vk.Budgets) > 0 { + budgetID := vk.Budgets[0].ID + if budgetValue, exists := store.budgets.Load(budgetID); exists && budgetValue != nil { if budget, ok := budgetValue.(*configstoreTables.TableBudget); ok && budget != nil { budget.CurrentUsage = 100.0 - store.budgets.Store(*vk.BudgetID, budget) + store.budgets.Store(budgetID, budget) } } } err = store.CheckBudget(context.Background(), vk, &EvaluationRequest{Provider: schemas.OpenAI}, nil) - assert.Error(t, err, "Should fail when VK budget exceeds limit") + require.Error(t, err, "Should fail when VK budget exceeds limit") +} + +// TestGovernanceStore_MultiBudget_AllUnderLimit tests that requests pass when all budgets are under their limits +func TestGovernanceStore_MultiBudget_AllUnderLimit(t *testing.T) { + logger := NewMockLogger() + + // Create VK with hourly ($10) and daily ($100) budgets + hourlyBudget := buildBudgetWithUsage("hourly", 10.0, 5.0, "1h") + dailyBudget := buildBudgetWithUsage("daily", 100.0, 40.0, "1d") + + vk := buildVirtualKeyWithMultiBudgets("vk1", "sk-bf-test", "Test VK", + []configstoreTables.TableBudget{*hourlyBudget, *dailyBudget}) + // Add provider config so the resolver allows the provider + vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{ + buildProviderConfig("openai", []string{"*"}), + } + + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*vk}, + Budgets: []configstoreTables.TableBudget{*hourlyBudget, *dailyBudget}, + }, nil) + require.NoError(t, err) + + vk, _ = store.GetVirtualKey("sk-bf-test") + err = store.CheckBudget(context.Background(), vk, &EvaluationRequest{Provider: schemas.OpenAI}, nil) + assert.NoError(t, err, "Should pass when all budgets are under limit") +} + +// TestGovernanceStore_MultiBudget_SmallBudgetExceeded tests that request is blocked when the smaller budget exceeds its limit +func TestGovernanceStore_MultiBudget_SmallBudgetExceeded(t *testing.T) { + logger := NewMockLogger() + + // Hourly at limit, daily still has room + hourlyBudget := buildBudgetWithUsage("hourly", 10.0, 10.0, "1h") + dailyBudget := buildBudgetWithUsage("daily", 100.0, 40.0, "1d") + + vk := buildVirtualKeyWithMultiBudgets("vk1", "sk-bf-test", "Test VK", + []configstoreTables.TableBudget{*hourlyBudget, *dailyBudget}) + vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{ + buildProviderConfig("openai", []string{"*"}), + } + + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*vk}, + Budgets: []configstoreTables.TableBudget{*hourlyBudget, *dailyBudget}, + }, nil) + require.NoError(t, err) + + vk, _ = store.GetVirtualKey("sk-bf-test") + err = store.CheckBudget(context.Background(), vk, &EvaluationRequest{Provider: schemas.OpenAI}, nil) + require.Error(t, err, "Should fail when hourly budget is exceeded even though daily is fine") + assert.Contains(t, err.Error(), "budget exceeded") +} + +// TestGovernanceStore_MultiBudget_LargeBudgetExceeded tests that request is blocked when only the larger budget exceeds +func TestGovernanceStore_MultiBudget_LargeBudgetExceeded(t *testing.T) { + logger := NewMockLogger() + + // Hourly has room, but daily is at limit + hourlyBudget := buildBudgetWithUsage("hourly", 10.0, 3.0, "1h") + dailyBudget := buildBudgetWithUsage("daily", 100.0, 100.0, "1d") + + vk := buildVirtualKeyWithMultiBudgets("vk1", "sk-bf-test", "Test VK", + []configstoreTables.TableBudget{*hourlyBudget, *dailyBudget}) + vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{ + buildProviderConfig("openai", []string{"*"}), + } + + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*vk}, + Budgets: []configstoreTables.TableBudget{*hourlyBudget, *dailyBudget}, + }, nil) + require.NoError(t, err) + + vk, _ = store.GetVirtualKey("sk-bf-test") + err = store.CheckBudget(context.Background(), vk, &EvaluationRequest{Provider: schemas.OpenAI}, nil) + require.Error(t, err, "Should fail when daily budget is exceeded even though hourly is fine") + assert.Contains(t, err.Error(), "budget exceeded") +} + +// TestGovernanceStore_MultiBudget_UsageUpdatesAllBudgets tests that usage updates are applied to every budget in the hierarchy +func TestGovernanceStore_MultiBudget_UsageUpdatesAllBudgets(t *testing.T) { + logger := NewMockLogger() + + hourlyBudget := buildBudget("hourly", 10.0, "1h") + dailyBudget := buildBudget("daily", 100.0, "1d") + + vk := buildVirtualKeyWithMultiBudgets("vk1", "sk-bf-test", "Test VK", + []configstoreTables.TableBudget{*hourlyBudget, *dailyBudget}) + vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{ + buildProviderConfig("openai", []string{"*"}), + } + + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*vk}, + Budgets: []configstoreTables.TableBudget{*hourlyBudget, *dailyBudget}, + }, nil) + require.NoError(t, err) + + vk, _ = store.GetVirtualKey("sk-bf-test") + + // Simulate a $3.50 request + err = store.UpdateVirtualKeyBudgetUsageInMemory(context.Background(), vk, schemas.OpenAI, 3.50) + require.NoError(t, err) + + // Both budgets should reflect the cost + hourlyVal, exists := store.budgets.Load("hourly") + require.True(t, exists) + assert.InDelta(t, 3.50, hourlyVal.(*configstoreTables.TableBudget).CurrentUsage, 0.01, "Hourly budget should reflect usage") + + dailyVal, exists := store.budgets.Load("daily") + require.True(t, exists) + assert.InDelta(t, 3.50, dailyVal.(*configstoreTables.TableBudget).CurrentUsage, 0.01, "Daily budget should reflect usage") + + // Second request: $7.00 — should push hourly over limit + err = store.UpdateVirtualKeyBudgetUsageInMemory(context.Background(), vk, schemas.OpenAI, 7.00) + require.NoError(t, err) + + hourlyVal, _ = store.budgets.Load("hourly") + assert.InDelta(t, 10.50, hourlyVal.(*configstoreTables.TableBudget).CurrentUsage, 0.01, "Hourly budget should accumulate") + + dailyVal, _ = store.budgets.Load("daily") + assert.InDelta(t, 10.50, dailyVal.(*configstoreTables.TableBudget).CurrentUsage, 0.01, "Daily budget should accumulate") + + // Now CheckBudget should fail (hourly exceeded) + err = store.CheckBudget(context.Background(), vk, &EvaluationRequest{Provider: schemas.OpenAI}, nil) + require.Error(t, err, "Should fail after usage exceeds hourly budget") + assert.Contains(t, err.Error(), "budget exceeded") +} + +// TestGovernanceStore_MultiBudget_ProviderConfigBudgets tests that provider-config-level multi-budgets are enforced +func TestGovernanceStore_MultiBudget_ProviderConfigBudgets(t *testing.T) { + logger := NewMockLogger() + + // Provider-level budgets: hourly $5 (exceeded), daily $50 (ok) + pcHourly := buildBudgetWithUsage("pc-hourly", 5.0, 5.0, "1h") + pcDaily := buildBudgetWithUsage("pc-daily", 50.0, 10.0, "1d") + + pc := buildProviderConfigWithBudgets("openai", []string{"*"}, + []configstoreTables.TableBudget{*pcHourly, *pcDaily}) + + vk := buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test VK", + []configstoreTables.TableVirtualKeyProviderConfig{pc}) + + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*vk}, + Budgets: []configstoreTables.TableBudget{*pcHourly, *pcDaily}, + }, nil) + require.NoError(t, err) + + vk, _ = store.GetVirtualKey("sk-bf-test") + err = store.CheckBudget(context.Background(), vk, &EvaluationRequest{Provider: schemas.OpenAI}, nil) + require.Error(t, err, "Should fail when provider config hourly budget is exceeded") + assert.Contains(t, err.Error(), "budget exceeded") +} + +// TestGovernanceStore_MultiBudget_VKAndProviderConfigCombined tests budgets at both VK and provider config levels +func TestGovernanceStore_MultiBudget_VKAndProviderConfigCombined(t *testing.T) { + logger := NewMockLogger() + + // VK-level budgets: all under limit + vkMonthly := buildBudgetWithUsage("vk-monthly", 1000.0, 200.0, "1M") + + // Provider-config-level budgets: hourly at limit + pcHourly := buildBudgetWithUsage("pc-hourly", 5.0, 5.0, "1h") + + pc := buildProviderConfigWithBudgets("openai", []string{"*"}, + []configstoreTables.TableBudget{*pcHourly}) + + vk := buildVirtualKeyWithMultiBudgets("vk1", "sk-bf-test", "Test VK", + []configstoreTables.TableBudget{*vkMonthly}) + vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{pc} + + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*vk}, + Budgets: []configstoreTables.TableBudget{*vkMonthly, *pcHourly}, + }, nil) + require.NoError(t, err) + + vk, _ = store.GetVirtualKey("sk-bf-test") + + // Provider config budget exceeded → should block even though VK budget is fine + err = store.CheckBudget(context.Background(), vk, &EvaluationRequest{Provider: schemas.OpenAI}, nil) + require.Error(t, err, "Should fail: provider config budget exceeded even though VK budget is fine") + assert.Contains(t, err.Error(), "budget exceeded") +} + +// TestGovernanceStore_MultiBudget_ResolverBlocksOnBudgetExceeded tests that the full resolver flow blocks when any budget is exceeded +func TestGovernanceStore_MultiBudget_ResolverBlocksOnBudgetExceeded(t *testing.T) { + logger := NewMockLogger() + + // Two VK-level budgets: hourly at limit, daily has room + hourlyBudget := buildBudgetWithUsage("hourly", 10.0, 10.0, "1h") + dailyBudget := buildBudgetWithUsage("daily", 100.0, 30.0, "1d") + + vk := buildVirtualKeyWithMultiBudgets("vk1", "sk-bf-test", "Test VK", + []configstoreTables.TableBudget{*hourlyBudget, *dailyBudget}) + vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{ + buildProviderConfig("openai", []string{"*"}), + } + + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*vk}, + Budgets: []configstoreTables.TableBudget{*hourlyBudget, *dailyBudget}, + }, nil) + require.NoError(t, err) + + resolver := NewBudgetResolver(store, nil, logger) + ctx := &schemas.BifrostContext{} + + result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false) + assertDecision(t, DecisionBudgetExceeded, result) + assert.Contains(t, result.Reason, "budget exceeded") +} + +// TestGovernanceStore_MultiBudget_ResolverAllowsUnderLimit tests that the full resolver flow allows requests when all budgets are under limit +func TestGovernanceStore_MultiBudget_ResolverAllowsUnderLimit(t *testing.T) { + logger := NewMockLogger() + + hourlyBudget := buildBudgetWithUsage("hourly", 10.0, 5.0, "1h") + dailyBudget := buildBudgetWithUsage("daily", 100.0, 30.0, "1d") + + vk := buildVirtualKeyWithMultiBudgets("vk1", "sk-bf-test", "Test VK", + []configstoreTables.TableBudget{*hourlyBudget, *dailyBudget}) + vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{ + buildProviderConfig("openai", []string{"*"}), + } + + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*vk}, + Budgets: []configstoreTables.TableBudget{*hourlyBudget, *dailyBudget}, + }, nil) + require.NoError(t, err) + + resolver := NewBudgetResolver(store, nil, logger) + ctx := &schemas.BifrostContext{} + + result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false) + assertDecision(t, DecisionAllow, result) +} + +// TestGovernanceStore_MultiBudget_UsageDrivesBlockAfterRequests tests the full lifecycle: +// start under limit → accumulate usage → eventually hit a budget → get blocked +func TestGovernanceStore_MultiBudget_UsageDrivesBlockAfterRequests(t *testing.T) { + logger := NewMockLogger() + + // Tight hourly ($2), generous daily ($100) + hourlyBudget := buildBudget("hourly", 2.0, "1h") + dailyBudget := buildBudget("daily", 100.0, "1d") + + vk := buildVirtualKeyWithMultiBudgets("vk1", "sk-bf-test", "Test VK", + []configstoreTables.TableBudget{*hourlyBudget, *dailyBudget}) + vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{ + buildProviderConfig("openai", []string{"*"}), + } + + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*vk}, + Budgets: []configstoreTables.TableBudget{*hourlyBudget, *dailyBudget}, + }, nil) + require.NoError(t, err) + + resolver := NewBudgetResolver(store, nil, logger) + + // Request 1: $0.80 — both budgets fine + vk, _ = store.GetVirtualKey("sk-bf-test") + err = store.UpdateVirtualKeyBudgetUsageInMemory(context.Background(), vk, schemas.OpenAI, 0.80) + require.NoError(t, err) + + ctx := &schemas.BifrostContext{} + result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false) + assertDecision(t, DecisionAllow, result) + + // Request 2: $0.80 — still fine ($1.60 total) + vk, _ = store.GetVirtualKey("sk-bf-test") + err = store.UpdateVirtualKeyBudgetUsageInMemory(context.Background(), vk, schemas.OpenAI, 0.80) + require.NoError(t, err) + + ctx = &schemas.BifrostContext{} + result = resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false) + assertDecision(t, DecisionAllow, result) + + // Request 3: $0.80 — pushes hourly to $2.40 > $2.00 limit → blocked + vk, _ = store.GetVirtualKey("sk-bf-test") + err = store.UpdateVirtualKeyBudgetUsageInMemory(context.Background(), vk, schemas.OpenAI, 0.80) + require.NoError(t, err) + + ctx = &schemas.BifrostContext{} + result = resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false) + assertDecision(t, DecisionBudgetExceeded, result) + assert.Contains(t, result.Reason, "budget exceeded") + + // Verify daily budget is still under limit + dailyVal, exists := store.budgets.Load("daily") + require.True(t, exists) + assert.InDelta(t, 2.40, dailyVal.(*configstoreTables.TableBudget).CurrentUsage, 0.01, + "Daily budget should be at $2.40, well under $100 limit") +} + +// TestGovernanceStore_MultiBudget_CalendarAligned tests that calendar-aligned budgets are stored and retrievable +func TestGovernanceStore_MultiBudget_CalendarAligned(t *testing.T) { + logger := NewMockLogger() + + // Calendar alignment is a VK-level setting — budgets don't have it + dailyBudget := &configstoreTables.TableBudget{ + ID: "daily-cal", + MaxLimit: 50.0, + CurrentUsage: 10.0, + ResetDuration: "1d", + LastReset: time.Now(), + } + monthlyBudget := &configstoreTables.TableBudget{ + ID: "monthly-cal", + MaxLimit: 1000.0, + CurrentUsage: 200.0, + ResetDuration: "1M", + LastReset: time.Now(), + } + + vk := buildVirtualKeyWithMultiBudgets("vk1", "sk-bf-test", "Test VK", + []configstoreTables.TableBudget{*dailyBudget, *monthlyBudget}) + vk.CalendarAligned = true // VK-level setting applies to all budgets + vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{ + buildProviderConfig("openai", []string{"*"}), + } + + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*vk}, + Budgets: []configstoreTables.TableBudget{*dailyBudget, *monthlyBudget}, + }, nil) + require.NoError(t, err) + + // Verify VK-level calendar_aligned is set + vk, _ = store.GetVirtualKey("sk-bf-test") + assert.True(t, vk.CalendarAligned, "VK should have calendar_aligned=true") + + // Both under limit — should pass + err = store.CheckBudget(context.Background(), vk, &EvaluationRequest{Provider: schemas.OpenAI}, nil) + assert.NoError(t, err) +} + +// TestGovernanceStore_MultiBudget_InMemoryCreateAndDelete tests CreateVirtualKeyInMemory and DeleteVirtualKeyInMemory +// properly store and clean up multi-budget entries +func TestGovernanceStore_MultiBudget_InMemoryCreateAndDelete(t *testing.T) { + logger := NewMockLogger() + + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{}, nil) + require.NoError(t, err) + + b1 := buildBudget("b1", 10.0, "1h") + b2 := buildBudget("b2", 100.0, "1d") + + vk := buildVirtualKeyWithMultiBudgets("vk1", "sk-bf-test", "Test VK", + []configstoreTables.TableBudget{*b1, *b2}) + vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{ + buildProviderConfig("openai", []string{"*"}), + } + + // Create + store.CreateVirtualKeyInMemory(vk) + + _, exists := store.budgets.Load("b1") + assert.True(t, exists, "Budget b1 should be in memory after create") + _, exists = store.budgets.Load("b2") + assert.True(t, exists, "Budget b2 should be in memory after create") + + retrieved, found := store.GetVirtualKey("sk-bf-test") + require.True(t, found) + assert.Len(t, retrieved.Budgets, 2, "VK should have 2 budgets") + + // Delete + store.DeleteVirtualKeyInMemory("vk1") + + _, exists = store.budgets.Load("b1") + assert.False(t, exists, "Budget b1 should be removed after delete") + _, exists = store.budgets.Load("b2") + assert.False(t, exists, "Budget b2 should be removed after delete") + + _, found = store.GetVirtualKey("sk-bf-test") + assert.False(t, found, "VK should not be found after delete") } // TestGovernanceStore_UpdateRateLimitUsage_TokensAndRequests tests atomic rate limit usage updates @@ -319,9 +700,9 @@ func TestGovernanceStore_ResetExpiredBudgets(t *testing.T) { // Retrieve the updated VK to check budget changes updatedVK, _ := store.GetVirtualKey("sk-bf-test") require.NotNil(t, updatedVK) - require.NotNil(t, updatedVK.Budget) + require.True(t, len(updatedVK.Budgets) > 0, "VK should have budgets") - assert.Equal(t, 0.0, updatedVK.Budget.CurrentUsage, "Budget usage should be reset") + assert.Equal(t, 0.0, updatedVK.Budgets[0].CurrentUsage, "Budget usage should be reset") } // TestGovernanceStore_GetAllBudgets tests retrieving all budgets diff --git a/plugins/governance/test_utils.go b/plugins/governance/test_utils.go index ccd1e5968d..b7ad6dbad3 100644 --- a/plugins/governance/test_utils.go +++ b/plugins/governance/test_utils.go @@ -15,7 +15,6 @@ import ( configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" "github.com/maximhq/bifrost/framework/modelcatalog" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) // MockLogger implements schemas.Logger for testing @@ -89,9 +88,10 @@ func buildVirtualKey(id, value, name string, isActive bool) *configstoreTables.T func buildVirtualKeyWithBudget(id, value, name string, budget *configstoreTables.TableBudget) *configstoreTables.TableVirtualKey { vk := buildVirtualKey(id, value, name, true) - vk.Budget = budget - budgetID := budget.ID - vk.BudgetID = &budgetID + vkID := id + budget.VirtualKeyID = &vkID + vk.Budgets = []configstoreTables.TableBudget{*budget} + // Add a default provider config so the resolver doesn't block at provider check vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{ buildProviderConfig("openai", []string{"*"}), } @@ -103,6 +103,7 @@ func buildVirtualKeyWithRateLimit(id, value, name string, rateLimit *configstore vk.RateLimit = rateLimit rateLimitID := rateLimit.ID vk.RateLimitID = &rateLimitID + // Add a default provider config so the resolver doesn't block at provider check vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{ buildProviderConfig("openai", []string{"*"}), } @@ -195,11 +196,26 @@ func buildProviderConfig(provider string, allowedModels []string) configstoreTab AllowedModels: allowedModels, Weight: bifrost.Ptr(1.0), RateLimit: nil, - Budget: nil, Keys: []configstoreTables.TableKey{}, } } +func buildProviderConfigWithBudgets(provider string, allowedModels []string, budgets []configstoreTables.TableBudget) configstoreTables.TableVirtualKeyProviderConfig { + pc := buildProviderConfig(provider, allowedModels) + pc.Budgets = budgets + return pc +} + +func buildVirtualKeyWithMultiBudgets(id, value, name string, budgets []configstoreTables.TableBudget) *configstoreTables.TableVirtualKey { + vk := buildVirtualKey(id, value, name, true) + for i := range budgets { + vkID := id + budgets[i].VirtualKeyID = &vkID + } + vk.Budgets = budgets + return vk +} + func buildProviderConfigWithRateLimit(provider string, allowedModels []string, rateLimit *configstoreTables.TableRateLimit) configstoreTables.TableVirtualKeyProviderConfig { pc := buildProviderConfig(provider, allowedModels) pc.RateLimit = rateLimit @@ -227,15 +243,6 @@ func assertRateLimitInfo(t *testing.T, result *EvaluationResult) { assert.NotNil(t, result.RateLimitInfo, "RateLimitInfo should be present in result") } -func requireNoError(t *testing.T, err error, msg string) { - t.Helper() - require.NoError(t, err, msg) -} - -func requireError(t *testing.T, err error, msg string) { - t.Helper() - require.Error(t, err, msg) -} func buildModelConfig(id, modelName string, provider *string, budget *configstoreTables.TableBudget, rateLimit *configstoreTables.TableRateLimit) *configstoreTables.TableModelConfig { mc := &configstoreTables.TableModelConfig{ diff --git a/transports/bifrost-http/handlers/governance.go b/transports/bifrost-http/handlers/governance.go index b21008983b..cd5b2250de 100644 --- a/transports/bifrost-http/handlers/governance.go +++ b/transports/bifrost-http/handlers/governance.go @@ -73,7 +73,7 @@ type CreateVirtualKeyRequest struct { Provider string `json:"provider" validate:"required"` Weight *float64 `json:"weight,omitempty"` AllowedModels schemas.WhiteList `json:"allowed_models,omitempty"` // ["*"] allows all models; empty denies all - Budget *CreateBudgetRequest `json:"budget,omitempty"` // Provider-level budget + Budgets []CreateBudgetRequest `json:"budgets,omitempty"` // Multi-budget for provider config RateLimit *CreateRateLimitRequest `json:"rate_limit,omitempty"` // Provider-level rate limit KeyIDs schemas.WhiteList `json:"key_ids,omitempty"` // List of DBKey UUIDs to associate with this provider config } `json:"provider_configs,omitempty"` // Empty means no providers allowed (deny-by-default) @@ -83,9 +83,10 @@ type CreateVirtualKeyRequest struct { } `json:"mcp_configs,omitempty"` // Empty means no MCP clients allowed (deny-by-default) TeamID *string `json:"team_id,omitempty"` // Mutually exclusive with CustomerID CustomerID *string `json:"customer_id,omitempty"` // Mutually exclusive with TeamID - Budget *CreateBudgetRequest `json:"budget,omitempty"` - RateLimit *CreateRateLimitRequest `json:"rate_limit,omitempty"` - IsActive *bool `json:"is_active,omitempty"` + Budgets []CreateBudgetRequest `json:"budgets,omitempty"` // Multi-budget: each must have a unique reset_duration + RateLimit *CreateRateLimitRequest `json:"rate_limit,omitempty"` + IsActive *bool `json:"is_active,omitempty"` + CalendarAligned bool `json:"calendar_aligned,omitempty"` // When true, all budgets reset at clean calendar boundaries } // UpdateVirtualKeyRequest represents the request body for updating a virtual key @@ -97,7 +98,7 @@ type UpdateVirtualKeyRequest struct { Provider string `json:"provider" validate:"required"` Weight *float64 `json:"weight,omitempty"` AllowedModels schemas.WhiteList `json:"allowed_models,omitempty"` // ["*"] allows all models; empty denies all - Budget *UpdateBudgetRequest `json:"budget,omitempty"` // Provider-level budget + Budgets []CreateBudgetRequest `json:"budgets,omitempty"` // Multi-budget for provider config RateLimit *UpdateRateLimitRequest `json:"rate_limit,omitempty"` // Provider-level rate limit KeyIDs schemas.WhiteList `json:"key_ids,omitempty"` // List of DBKey UUIDs to associate with this provider config } `json:"provider_configs,omitempty"` @@ -108,9 +109,10 @@ type UpdateVirtualKeyRequest struct { } `json:"mcp_configs,omitempty"` TeamID *string `json:"team_id,omitempty"` CustomerID *string `json:"customer_id,omitempty"` - Budget *UpdateBudgetRequest `json:"budget,omitempty"` - RateLimit *UpdateRateLimitRequest `json:"rate_limit,omitempty"` - IsActive *bool `json:"is_active,omitempty"` + Budgets []CreateBudgetRequest `json:"budgets,omitempty"` // Multi-budget: replaces all VK-level budgets + RateLimit *UpdateRateLimitRequest `json:"rate_limit,omitempty"` + IsActive *bool `json:"is_active,omitempty"` + CalendarAligned *bool `json:"calendar_aligned,omitempty"` // When true, all budgets reset at clean calendar boundaries } // CreateBudgetRequest represents the request body for creating a budget @@ -207,8 +209,8 @@ func collectProviderConfigDeleteIDs( budgetIDs []string, rateLimitIDs []string, ) ([]string, []string) { - if config.BudgetID != nil { - budgetIDs = append(budgetIDs, *config.BudgetID) + for _, b := range config.Budgets { + budgetIDs = append(budgetIDs, b.ID) } if config.RateLimitID != nil { rateLimitIDs = append(rateLimitIDs, *config.RateLimitID) @@ -439,16 +441,23 @@ func (h *GovernanceHandler) createVirtualKey(ctx *fasthttp.RequestCtx) { SendError(ctx, 400, "VirtualKey cannot be attached to both Team and Customer") return } - // Validate budget if provided - if req.Budget != nil { - if req.Budget.MaxLimit < 0 { - SendError(ctx, 400, fmt.Sprintf("Budget max_limit cannot be negative: %.2f", req.Budget.MaxLimit)) - return - } - // Validate reset duration format - if _, err := configstoreTables.ParseDuration(req.Budget.ResetDuration); err != nil { - SendError(ctx, 400, fmt.Sprintf("Invalid reset duration format: %s", req.Budget.ResetDuration)) - return + // Validate budgets if provided + if len(req.Budgets) > 0 { + seenDurations := make(map[string]bool) + for _, b := range req.Budgets { + if b.MaxLimit < 0 { + SendError(ctx, 400, fmt.Sprintf("Budget max_limit cannot be negative: %.2f", b.MaxLimit)) + return + } + if _, err := configstoreTables.ParseDuration(b.ResetDuration); err != nil { + SendError(ctx, 400, fmt.Sprintf("Invalid reset duration format: %s", b.ResetDuration)) + return + } + if seenDurations[b.ResetDuration] { + SendError(ctx, 400, fmt.Sprintf("Duplicate reset_duration in budgets: %s", b.ResetDuration)) + return + } + seenDurations[b.ResetDuration] = true } } // Set defaults @@ -459,30 +468,14 @@ func (h *GovernanceHandler) createVirtualKey(ctx *fasthttp.RequestCtx) { var vk configstoreTables.TableVirtualKey if err := h.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { vk = configstoreTables.TableVirtualKey{ - ID: uuid.NewString(), - Name: req.Name, - Value: governance.GenerateVirtualKey(), - Description: req.Description, - TeamID: req.TeamID, - CustomerID: req.CustomerID, - IsActive: isActive, - } - if req.Budget != nil { - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: req.Budget.MaxLimit, - ResetDuration: req.Budget.ResetDuration, - CalendarAligned: req.Budget.CalendarAligned, - LastReset: budgetLastReset(req.Budget.CalendarAligned, req.Budget.ResetDuration), - CurrentUsage: 0, - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { - return err - } - vk.BudgetID = &budget.ID + ID: uuid.NewString(), + Name: req.Name, + Value: governance.GenerateVirtualKey(), + Description: req.Description, + TeamID: req.TeamID, + CustomerID: req.CustomerID, + IsActive: isActive, + CalendarAligned: req.CalendarAligned, } if req.RateLimit != nil { rateLimit := configstoreTables.TableRateLimit{ @@ -505,19 +498,27 @@ func (h *GovernanceHandler) createVirtualKey(ctx *fasthttp.RequestCtx) { if err := h.configStore.CreateVirtualKey(ctx, &vk, tx); err != nil { return err } + // Create multi-budgets for VK + if len(req.Budgets) > 0 { + for _, b := range req.Budgets { + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: b.MaxLimit, + ResetDuration: b.ResetDuration, + LastReset: budgetLastReset(vk.CalendarAligned, b.ResetDuration), + CurrentUsage: 0, + VirtualKeyID: &vk.ID, + } + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + } + } if req.ProviderConfigs != nil { for _, pc := range req.ProviderConfigs { - // Validate budget if provided - if pc.Budget != nil { - if pc.Budget.MaxLimit < 0 { - return fmt.Errorf("provider config budget max_limit cannot be negative: %.2f", pc.Budget.MaxLimit) - } - // Validate reset duration format - if _, err := configstoreTables.ParseDuration(pc.Budget.ResetDuration); err != nil { - return fmt.Errorf("invalid provider config budget reset duration format: %s", pc.Budget.ResetDuration) - } - } - if err := pc.AllowedModels.Validate(); err != nil { return &badRequestError{err: fmt.Errorf("invalid allowed_models for provider %s: %w", pc.Provider, err)} } @@ -550,24 +551,6 @@ func (h *GovernanceHandler) createVirtualKey(ctx *fasthttp.RequestCtx) { Keys: keys, } - // Create budget for provider config if provided - if pc.Budget != nil { - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: pc.Budget.MaxLimit, - ResetDuration: pc.Budget.ResetDuration, - CalendarAligned: pc.Budget.CalendarAligned, - LastReset: budgetLastReset(pc.Budget.CalendarAligned, pc.Budget.ResetDuration), - CurrentUsage: 0, - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { - return err - } - providerConfig.BudgetID = &budget.ID - } // Create rate limit for provider config if provided if pc.RateLimit != nil { rateLimit := configstoreTables.TableRateLimit{ @@ -591,6 +574,30 @@ func (h *GovernanceHandler) createVirtualKey(ctx *fasthttp.RequestCtx) { if err := h.configStore.CreateVirtualKeyProviderConfig(ctx, providerConfig, tx); err != nil { return err } + // Create multi-budgets for provider config + if len(pc.Budgets) > 0 { + seenDurations := make(map[string]bool) + for _, b := range pc.Budgets { + if seenDurations[b.ResetDuration] { + return &badRequestError{err: fmt.Errorf("duplicate reset_duration in provider config budgets: %s", b.ResetDuration)} + } + seenDurations[b.ResetDuration] = true + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: b.MaxLimit, + ResetDuration: b.ResetDuration, + LastReset: budgetLastReset(vk.CalendarAligned, b.ResetDuration), + CurrentUsage: 0, + ProviderConfigID: &providerConfig.ID, + } + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + } + } } } if req.MCPConfigs != nil { @@ -702,8 +709,9 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) { return } if err := h.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { - var budgetIDToDelete, rateLimitIDToDelete string - var providerBudgetIDsToDelete, providerRateLimitIDsToDelete []string + var rateLimitIDToDelete string + var providerBudgetIDsToDelete []string + var providerRateLimitIDsToDelete []string // Update fields if provided if req.Name != nil { @@ -728,72 +736,77 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) { if req.IsActive != nil { vk.IsActive = *req.IsActive } - // Handle budget updates - if req.Budget != nil { - if isBudgetRemovalRequest(req.Budget) { - if vk.BudgetID != nil { - budgetIDToDelete = *vk.BudgetID - vk.BudgetID = nil - vk.Budget = nil - } - } else if vk.BudgetID != nil { - // Update existing budget - budget := configstoreTables.TableBudget{} - if err := tx.First(&budget, "id = ?", *vk.BudgetID).Error; err != nil { - return err - } - - if req.Budget.MaxLimit != nil { - budget.MaxLimit = *req.Budget.MaxLimit - } - if req.Budget.ResetDuration != nil { - budget.ResetDuration = *req.Budget.ResetDuration - } - if req.Budget.CalendarAligned != nil { - wasCalendarAligned := budget.CalendarAligned - budget.CalendarAligned = *req.Budget.CalendarAligned - if *req.Budget.CalendarAligned && !wasCalendarAligned { - budget.LastReset = configstoreTables.GetCalendarPeriodStart(budget.ResetDuration, time.Now()) - budget.CurrentUsage = 0 + if req.CalendarAligned != nil { + vk.CalendarAligned = *req.CalendarAligned + } + // Handle multi-budget updates + if req.Budgets != nil { + // Validate multi-budgets + seenDurations := make(map[string]bool) + for _, b := range req.Budgets { + if b.MaxLimit < 0 { + return &badRequestError{err: fmt.Errorf("budget max_limit cannot be negative: %.2f", b.MaxLimit)} } - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.UpdateBudget(ctx, &budget, tx); err != nil { - return err - } - vk.Budget = &budget - } else { - // Create new budget - if req.Budget.MaxLimit == nil || req.Budget.ResetDuration == nil { - return fmt.Errorf("both max_limit and reset_duration are required when creating a new budget") + if _, err := configstoreTables.ParseDuration(b.ResetDuration); err != nil { + return &badRequestError{err: fmt.Errorf("invalid reset duration format: %s", b.ResetDuration)} } - if *req.Budget.MaxLimit < 0 { - return fmt.Errorf("budget max_limit cannot be negative: %.2f", *req.Budget.MaxLimit) + if seenDurations[b.ResetDuration] { + return &badRequestError{err: fmt.Errorf("duplicate reset_duration in budgets: %s", b.ResetDuration)} } - if _, err := configstoreTables.ParseDuration(*req.Budget.ResetDuration); err != nil { - return fmt.Errorf("invalid reset duration format: %s", *req.Budget.ResetDuration) - } - calAligned := req.Budget.CalendarAligned != nil && *req.Budget.CalendarAligned - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: *req.Budget.MaxLimit, - ResetDuration: *req.Budget.ResetDuration, - CalendarAligned: calAligned, - LastReset: budgetLastReset(calAligned, *req.Budget.ResetDuration), - CurrentUsage: 0, + seenDurations[b.ResetDuration] = true } - if err := validateBudget(&budget); err != nil { - return err + + // Build map of existing budgets by reset_duration for matching + existingByDuration := make(map[string]configstoreTables.TableBudget) + for _, existing := range vk.Budgets { + existingByDuration[existing.ResetDuration] = existing } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { - return err + + // Reconcile: preserve existing budgets where possible, create new ones where needed + var reconciledBudgets []configstoreTables.TableBudget + matchedIDs := make(map[string]bool) + for _, b := range req.Budgets { + if existing, found := existingByDuration[b.ResetDuration]; found { + // Budget with same duration exists — update max_limit, preserve usage + existing.MaxLimit = b.MaxLimit + if err := validateBudget(&existing); err != nil { + return err + } + if err := h.configStore.UpdateBudget(ctx, &existing, tx); err != nil { + return err + } + reconciledBudgets = append(reconciledBudgets, existing) + matchedIDs[existing.ID] = true + } else { + // New budget duration — create fresh + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: b.MaxLimit, + ResetDuration: b.ResetDuration, + LastReset: budgetLastReset(vk.CalendarAligned, b.ResetDuration), + CurrentUsage: 0, + VirtualKeyID: &vk.ID, + } + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + reconciledBudgets = append(reconciledBudgets, budget) + } } - vk.BudgetID = &budget.ID - vk.Budget = &budget + // Delete budgets that are no longer present + for _, existing := range vk.Budgets { + if !matchedIDs[existing.ID] { + if err := h.configStore.DeleteBudget(ctx, existing.ID, tx); err != nil { + return fmt.Errorf("failed to delete removed VK budget: %w", err) + } + } } + vk.Budgets = reconciledBudgets } + // Handle rate limit updates if req.RateLimit != nil { if isRateLimitRemovalRequest(req.RateLimit) { @@ -864,21 +877,6 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) { // Process new configs: create new ones and update existing ones for _, pc := range req.ProviderConfigs { if pc.ID == nil { - // Validate budget if provided for new provider config - if pc.Budget != nil { - if pc.Budget.MaxLimit != nil && *pc.Budget.MaxLimit < 0 { - return fmt.Errorf("provider config budget max_limit cannot be negative: %.2f", *pc.Budget.MaxLimit) - } - if pc.Budget.ResetDuration != nil { - if _, err := configstoreTables.ParseDuration(*pc.Budget.ResetDuration); err != nil { - return fmt.Errorf("invalid provider config budget reset duration format: %s", *pc.Budget.ResetDuration) - } - } - // Both fields are required when creating new budget - if pc.Budget.MaxLimit == nil || pc.Budget.ResetDuration == nil { - return fmt.Errorf("both max_limit and reset_duration are required when creating a new provider budget") - } - } if err := pc.AllowedModels.Validate(); err != nil { return &badRequestError{err: fmt.Errorf("invalid allowed_models for provider %s: %w", pc.Provider, err)} } @@ -911,25 +909,6 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) { AllowAllKeys: allowAllKeys, Keys: keys, } - // Create budget for provider config if provided - if pc.Budget != nil { - pcCalAligned := pc.Budget.CalendarAligned != nil && *pc.Budget.CalendarAligned - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: *pc.Budget.MaxLimit, - ResetDuration: *pc.Budget.ResetDuration, - CalendarAligned: pcCalAligned, - LastReset: budgetLastReset(pcCalAligned, *pc.Budget.ResetDuration), - CurrentUsage: 0, - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { - return err - } - providerConfig.BudgetID = &budget.ID - } // Create rate limit for provider config if provided if pc.RateLimit != nil { rateLimit := configstoreTables.TableRateLimit{ @@ -952,6 +931,30 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) { if err := h.configStore.CreateVirtualKeyProviderConfig(ctx, providerConfig, tx); err != nil { return err } + // Create multi-budgets for new provider config in update + if len(pc.Budgets) > 0 { + seenDurations := make(map[string]bool) + for _, b := range pc.Budgets { + if seenDurations[b.ResetDuration] { + return &badRequestError{err: fmt.Errorf("duplicate reset_duration in provider config budgets: %s", b.ResetDuration)} + } + seenDurations[b.ResetDuration] = true + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: b.MaxLimit, + ResetDuration: b.ResetDuration, + LastReset: budgetLastReset(vk.CalendarAligned, b.ResetDuration), + CurrentUsage: 0, + ProviderConfigID: &providerConfig.ID, + } + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + } + } } else { // Update existing provider config existing, ok := existingConfigsMap[*pc.ID] @@ -987,68 +990,72 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) { existing.AllowAllKeys = allowAllKeys existing.Keys = keys - // Handle budget updates for provider config - if pc.Budget != nil { - if isBudgetRemovalRequest(pc.Budget) { - if existing.BudgetID != nil { - providerBudgetIDsToDelete = append(providerBudgetIDsToDelete, *existing.BudgetID) - existing.BudgetID = nil - existing.Budget = nil - } - } else if existing.BudgetID != nil { - // Update existing budget - budget := configstoreTables.TableBudget{} - if err := tx.First(&budget, "id = ?", *existing.BudgetID).Error; err != nil { - return err - } - if pc.Budget.MaxLimit != nil { - budget.MaxLimit = *pc.Budget.MaxLimit - } - if pc.Budget.ResetDuration != nil { - budget.ResetDuration = *pc.Budget.ResetDuration - } - if pc.Budget.CalendarAligned != nil { - wasCalendarAligned := budget.CalendarAligned - budget.CalendarAligned = *pc.Budget.CalendarAligned - if *pc.Budget.CalendarAligned && !wasCalendarAligned { - budget.LastReset = configstoreTables.GetCalendarPeriodStart(budget.ResetDuration, time.Now()) - budget.CurrentUsage = 0 + // Handle multi-budget updates for existing provider config + if pc.Budgets != nil { + // Validate + seenDurations := make(map[string]bool) + for _, b := range pc.Budgets { + if b.MaxLimit < 0 { + return &badRequestError{err: fmt.Errorf("provider config budget max_limit cannot be negative: %.2f", b.MaxLimit)} } - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.UpdateBudget(ctx, &budget, tx); err != nil { - return err - } - } else { - // Create new budget for existing provider config - if pc.Budget.MaxLimit == nil || pc.Budget.ResetDuration == nil { - return fmt.Errorf("both max_limit and reset_duration are required when creating a new provider budget") + if _, err := configstoreTables.ParseDuration(b.ResetDuration); err != nil { + return &badRequestError{err: fmt.Errorf("invalid provider config budget reset duration format: %s", b.ResetDuration)} } - if *pc.Budget.MaxLimit < 0 { - return fmt.Errorf("provider config budget max_limit cannot be negative: %.2f", *pc.Budget.MaxLimit) + if seenDurations[b.ResetDuration] { + return &badRequestError{err: fmt.Errorf("duplicate reset_duration in provider config budgets: %s", b.ResetDuration)} } - if _, err := configstoreTables.ParseDuration(*pc.Budget.ResetDuration); err != nil { - return fmt.Errorf("invalid provider config budget reset duration format: %s", *pc.Budget.ResetDuration) - } - pcExistCalAligned := pc.Budget.CalendarAligned != nil && *pc.Budget.CalendarAligned - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: *pc.Budget.MaxLimit, - ResetDuration: *pc.Budget.ResetDuration, - CalendarAligned: pcExistCalAligned, - LastReset: budgetLastReset(pcExistCalAligned, *pc.Budget.ResetDuration), - CurrentUsage: 0, + seenDurations[b.ResetDuration] = true } - if err := validateBudget(&budget); err != nil { - return err + + // Build map of existing budgets by reset_duration for matching + pcExistingByDuration := make(map[string]configstoreTables.TableBudget) + for _, eb := range existing.Budgets { + pcExistingByDuration[eb.ResetDuration] = eb } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { - return err + + // Reconcile: preserve existing budgets where possible + var pcReconciledBudgets []configstoreTables.TableBudget + pcMatchedIDs := make(map[string]bool) + for _, b := range pc.Budgets { + if eb, found := pcExistingByDuration[b.ResetDuration]; found { + // Budget with same duration exists — update max_limit, preserve usage + eb.MaxLimit = b.MaxLimit + if err := validateBudget(&eb); err != nil { + return err + } + if err := h.configStore.UpdateBudget(ctx, &eb, tx); err != nil { + return err + } + pcReconciledBudgets = append(pcReconciledBudgets, eb) + pcMatchedIDs[eb.ID] = true + } else { + // New budget duration — create fresh + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: b.MaxLimit, + ResetDuration: b.ResetDuration, + LastReset: budgetLastReset(vk.CalendarAligned, b.ResetDuration), + CurrentUsage: 0, + ProviderConfigID: &existing.ID, + } + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + pcReconciledBudgets = append(pcReconciledBudgets, budget) + } } - existing.BudgetID = &budget.ID + // Delete budgets that are no longer present + for _, eb := range existing.Budgets { + if !pcMatchedIDs[eb.ID] { + if err := h.configStore.DeleteBudget(ctx, eb.ID, tx); err != nil { + return fmt.Errorf("failed to delete removed provider config budget: %w", err) + } + } } + existing.Budgets = pcReconciledBudgets } // Handle rate limit updates for provider config if pc.RateLimit != nil { @@ -1179,11 +1186,6 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) { } } - if budgetIDToDelete != "" { - if err := tx.Delete(&configstoreTables.TableBudget{}, "id = ?", budgetIDToDelete).Error; err != nil { - return err - } - } if rateLimitIDToDelete != "" { if err := tx.Delete(&configstoreTables.TableRateLimit{}, "id = ?", rateLimitIDToDelete).Error; err != nil { return err @@ -1399,8 +1401,7 @@ func (h *GovernanceHandler) createTeam(ctx *fasthttp.RequestCtx) { ID: uuid.NewString(), MaxLimit: req.Budget.MaxLimit, ResetDuration: req.Budget.ResetDuration, - CalendarAligned: req.Budget.CalendarAligned, - LastReset: budgetLastReset(req.Budget.CalendarAligned, req.Budget.ResetDuration), + LastReset: budgetLastReset(false, req.Budget.ResetDuration), CurrentUsage: 0, } if err := validateBudget(&budget); err != nil { @@ -1540,14 +1541,6 @@ func (h *GovernanceHandler) updateTeam(ctx *fasthttp.RequestCtx) { if req.Budget.ResetDuration != nil { budget.ResetDuration = *req.Budget.ResetDuration } - if req.Budget.CalendarAligned != nil { - wasCalendarAligned := budget.CalendarAligned - budget.CalendarAligned = *req.Budget.CalendarAligned - if *req.Budget.CalendarAligned && !wasCalendarAligned { - budget.LastReset = configstoreTables.GetCalendarPeriodStart(budget.ResetDuration, time.Now()) - budget.CurrentUsage = 0 - } - } if err := validateBudget(&budget); err != nil { return err } @@ -1566,13 +1559,11 @@ func (h *GovernanceHandler) updateTeam(ctx *fasthttp.RequestCtx) { if _, err := configstoreTables.ParseDuration(*req.Budget.ResetDuration); err != nil { return fmt.Errorf("invalid reset duration format: %s", *req.Budget.ResetDuration) } - teamCalAligned := req.Budget.CalendarAligned != nil && *req.Budget.CalendarAligned budget := configstoreTables.TableBudget{ ID: uuid.NewString(), MaxLimit: *req.Budget.MaxLimit, ResetDuration: *req.Budget.ResetDuration, - CalendarAligned: teamCalAligned, - LastReset: budgetLastReset(teamCalAligned, *req.Budget.ResetDuration), + LastReset: budgetLastReset(false, *req.Budget.ResetDuration), CurrentUsage: 0, } if err := validateBudget(&budget); err != nil { @@ -1811,8 +1802,7 @@ func (h *GovernanceHandler) createCustomer(ctx *fasthttp.RequestCtx) { ID: uuid.NewString(), MaxLimit: req.Budget.MaxLimit, ResetDuration: req.Budget.ResetDuration, - CalendarAligned: req.Budget.CalendarAligned, - LastReset: budgetLastReset(req.Budget.CalendarAligned, req.Budget.ResetDuration), + LastReset: budgetLastReset(false, req.Budget.ResetDuration), CurrentUsage: 0, } if err := validateBudget(&budget); err != nil { @@ -1942,14 +1932,6 @@ func (h *GovernanceHandler) updateCustomer(ctx *fasthttp.RequestCtx) { if req.Budget.ResetDuration != nil { budget.ResetDuration = *req.Budget.ResetDuration } - if req.Budget.CalendarAligned != nil { - wasCalendarAligned := budget.CalendarAligned - budget.CalendarAligned = *req.Budget.CalendarAligned - if *req.Budget.CalendarAligned && !wasCalendarAligned { - budget.LastReset = configstoreTables.GetCalendarPeriodStart(budget.ResetDuration, time.Now()) - budget.CurrentUsage = 0 - } - } if err := validateBudget(&budget); err != nil { return err } @@ -1968,13 +1950,11 @@ func (h *GovernanceHandler) updateCustomer(ctx *fasthttp.RequestCtx) { if _, err := configstoreTables.ParseDuration(*req.Budget.ResetDuration); err != nil { return fmt.Errorf("invalid reset duration format: %s", *req.Budget.ResetDuration) } - custCalAligned := req.Budget.CalendarAligned != nil && *req.Budget.CalendarAligned budget := configstoreTables.TableBudget{ ID: uuid.NewString(), MaxLimit: *req.Budget.MaxLimit, ResetDuration: *req.Budget.ResetDuration, - CalendarAligned: custCalAligned, - LastReset: budgetLastReset(custCalAligned, *req.Budget.ResetDuration), + LastReset: budgetLastReset(false, *req.Budget.ResetDuration), CurrentUsage: 0, } if err := validateBudget(&budget); err != nil { @@ -2199,9 +2179,6 @@ func validateBudget(budget *configstoreTables.TableBudget) error { if _, err := configstoreTables.ParseDuration(budget.ResetDuration); err != nil { return fmt.Errorf("invalid budget reset duration format: %s", budget.ResetDuration) } - if budget.CalendarAligned && !configstoreTables.IsCalendarAlignableDuration(budget.ResetDuration) { - return fmt.Errorf("calendar_aligned is not supported for reset duration %q: only daily (d), weekly (w), monthly (M), and yearly (Y) periods support calendar alignment", budget.ResetDuration) - } return nil } @@ -2364,8 +2341,7 @@ func (h *GovernanceHandler) createModelConfig(ctx *fasthttp.RequestCtx) { ID: uuid.NewString(), MaxLimit: req.Budget.MaxLimit, ResetDuration: req.Budget.ResetDuration, - CalendarAligned: req.Budget.CalendarAligned, - LastReset: budgetLastReset(req.Budget.CalendarAligned, req.Budget.ResetDuration), + LastReset: budgetLastReset(false, req.Budget.ResetDuration), CurrentUsage: 0, } if err := validateBudget(&budget); err != nil { @@ -2470,14 +2446,6 @@ func (h *GovernanceHandler) updateModelConfig(ctx *fasthttp.RequestCtx) { if req.Budget.ResetDuration != nil { budget.ResetDuration = *req.Budget.ResetDuration } - if req.Budget.CalendarAligned != nil { - wasCalendarAligned := budget.CalendarAligned - budget.CalendarAligned = *req.Budget.CalendarAligned - if *req.Budget.CalendarAligned && !wasCalendarAligned { - budget.LastReset = configstoreTables.GetCalendarPeriodStart(budget.ResetDuration, time.Now()) - budget.CurrentUsage = 0 - } - } if err := validateBudget(&budget); err != nil { return err } @@ -2496,13 +2464,11 @@ func (h *GovernanceHandler) updateModelConfig(ctx *fasthttp.RequestCtx) { if _, err := configstoreTables.ParseDuration(*req.Budget.ResetDuration); err != nil { return fmt.Errorf("invalid reset duration format: %s", *req.Budget.ResetDuration) } - mcCalAligned := req.Budget.CalendarAligned != nil && *req.Budget.CalendarAligned budget := configstoreTables.TableBudget{ ID: uuid.NewString(), MaxLimit: *req.Budget.MaxLimit, ResetDuration: *req.Budget.ResetDuration, - CalendarAligned: mcCalAligned, - LastReset: budgetLastReset(mcCalAligned, *req.Budget.ResetDuration), + LastReset: budgetLastReset(false, *req.Budget.ResetDuration), CurrentUsage: 0, } if err := validateBudget(&budget); err != nil { @@ -2742,14 +2708,6 @@ func (h *GovernanceHandler) updateProviderGovernance(ctx *fasthttp.RequestCtx) { if req.Budget.ResetDuration != nil { budget.ResetDuration = *req.Budget.ResetDuration } - if req.Budget.CalendarAligned != nil { - wasCalendarAligned := budget.CalendarAligned - budget.CalendarAligned = *req.Budget.CalendarAligned - if *req.Budget.CalendarAligned && !wasCalendarAligned { - budget.LastReset = configstoreTables.GetCalendarPeriodStart(budget.ResetDuration, time.Now()) - budget.CurrentUsage = 0 - } - } if err := validateBudget(&budget); err != nil { return err } @@ -2762,13 +2720,11 @@ func (h *GovernanceHandler) updateProviderGovernance(ctx *fasthttp.RequestCtx) { if req.Budget.MaxLimit == nil || req.Budget.ResetDuration == nil { return fmt.Errorf("both max_limit and reset_duration are required when creating a new budget") } - provCalAligned := req.Budget.CalendarAligned != nil && *req.Budget.CalendarAligned budget := configstoreTables.TableBudget{ ID: uuid.NewString(), MaxLimit: *req.Budget.MaxLimit, ResetDuration: *req.Budget.ResetDuration, - CalendarAligned: provCalAligned, - LastReset: budgetLastReset(provCalAligned, *req.Budget.ResetDuration), + LastReset: budgetLastReset(false, *req.Budget.ResetDuration), CurrentUsage: 0, } if err := validateBudget(&budget); err != nil { diff --git a/transports/bifrost-http/handlers/governance_test.go b/transports/bifrost-http/handlers/governance_test.go index 0fb9c7e40f..581a22e7b1 100644 --- a/transports/bifrost-http/handlers/governance_test.go +++ b/transports/bifrost-http/handlers/governance_test.go @@ -272,7 +272,7 @@ func TestCollectProviderConfigDeleteIDs(t *testing.T) { { name: "collects both IDs", config: configstoreTables.TableVirtualKeyProviderConfig{ - BudgetID: &budgetID, + Budgets: []configstoreTables.TableBudget{{ID: budgetID}}, RateLimitID: &rateLimitID, }, wantBudgetIDs: []string{budgetID}, @@ -281,7 +281,7 @@ func TestCollectProviderConfigDeleteIDs(t *testing.T) { { name: "appends to existing slices", config: configstoreTables.TableVirtualKeyProviderConfig{ - BudgetID: &budgetID, + Budgets: []configstoreTables.TableBudget{{ID: budgetID}}, RateLimitID: &rateLimitID, }, initialBudgetIDs: []string{"budget-0"}, diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go index 19c8f3b7db..c1d39907b0 100644 --- a/transports/bifrost-http/lib/config.go +++ b/transports/bifrost-http/lib/config.go @@ -2237,7 +2237,6 @@ func reconcileVirtualKeyAssociations( // Update existing provider config from file existing.Weight = newPC.Weight existing.AllowedModels = newPC.AllowedModels - existing.BudgetID = newPC.BudgetID existing.RateLimitID = newPC.RateLimitID existing.Keys = newPC.Keys if err := store.UpdateVirtualKeyProviderConfig(ctx, &existing, tx); err != nil { diff --git a/transports/bifrost-http/lib/config_test.go b/transports/bifrost-http/lib/config_test.go index fe1e3bebee..e0edc1f923 100644 --- a/transports/bifrost-http/lib/config_test.go +++ b/transports/bifrost-http/lib/config_test.go @@ -240,8 +240,7 @@ End-to-end tests for virtual key provider configuration operations. | TestSQLite_VKProviderConfig_KeyReference | VK provider config key references work | | TestSQLite_VKProviderConfig_HashChangesOnKeyIDChange | Hash changes when key ID changes | | TestSQLite_VKProviderConfig_WeightAndAllowedModels | Weight and allowed models handled correctly | -| TestSQLite_VKProviderConfig_BudgetAndRateLimit | BudgetID/RateLimitID persisted correctly | -| TestGenerateVirtualKeyHash_ProviderConfigBudgetRateLimit | VK hash includes provider config budget/rate limit | +| TestGenerateVirtualKeyHash_ProviderConfigRateLimit | VK hash includes provider config rate limit | =================================================================================== SQLITE INTEGRATION TESTS - VK MCP CONFIGS @@ -6347,7 +6346,6 @@ func TestProviderHashComparison_BedrockConfigChangedInFile(t *testing.T) { func TestGenerateVirtualKeyHash(t *testing.T) { // Create a virtual key teamID := "team-1" - budgetID := "budget-1" vk1 := tables.TableVirtualKey{ ID: "vk-1", Name: "test-vk", @@ -6355,7 +6353,6 @@ func TestGenerateVirtualKeyHash(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &teamID, - BudgetID: &budgetID, } // Generate hash @@ -6376,7 +6373,6 @@ func TestGenerateVirtualKeyHash(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &teamID, - BudgetID: &budgetID, } hash2, err := configstore.GenerateVirtualKeyHash(vk2) @@ -6396,7 +6392,6 @@ func TestGenerateVirtualKeyHash(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &teamID, - BudgetID: &budgetID, } hash3, err := configstore.GenerateVirtualKeyHash(vk3) @@ -6416,7 +6411,6 @@ func TestGenerateVirtualKeyHash(t *testing.T) { Value: "vk_different", // Different value IsActive: true, TeamID: &teamID, - BudgetID: &budgetID, } hash4, err := configstore.GenerateVirtualKeyHash(vk4) @@ -6436,7 +6430,6 @@ func TestGenerateVirtualKeyHash(t *testing.T) { Value: "vk_abc123", IsActive: false, // Different IsActive TeamID: &teamID, - BudgetID: &budgetID, } hash5, err := configstore.GenerateVirtualKeyHash(vk5) @@ -6457,7 +6450,6 @@ func TestGenerateVirtualKeyHash(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &differentTeamID, // Different TeamID - BudgetID: &budgetID, } hash6, err := configstore.GenerateVirtualKeyHash(vk6) @@ -6477,7 +6469,6 @@ func TestGenerateVirtualKeyHash(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &teamID, - BudgetID: &budgetID, } hash7, err := configstore.GenerateVirtualKeyHash(vk7) @@ -6498,7 +6489,6 @@ func TestGenerateVirtualKeyHash(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &teamID, - BudgetID: &budgetID, CustomerID: &customerID, // CustomerID set } @@ -6520,7 +6510,6 @@ func TestGenerateVirtualKeyHash(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &teamID, - BudgetID: &budgetID, CustomerID: &differentCustomerID, // Different CustomerID } @@ -6533,27 +6522,6 @@ func TestGenerateVirtualKeyHash(t *testing.T) { t.Error("Expected different hash for virtual keys with different CustomerID values") } - // Different BudgetID should produce different hash - differentBudgetID := "budget-2" - vk9 := tables.TableVirtualKey{ - ID: "vk-1", - Name: "test-vk", - Description: "Test virtual key", - Value: "vk_abc123", - IsActive: true, - TeamID: &teamID, - BudgetID: &differentBudgetID, // Different BudgetID - } - - hash9, err := configstore.GenerateVirtualKeyHash(vk9) - if err != nil { - t.Fatalf("Failed to generate hash: %v", err) - } - - if hash1 == hash9 { - t.Error("Expected different hash for virtual keys with different BudgetID") - } - // RateLimitID should produce different hash rateLimitID := "ratelimit-1" vk10 := tables.TableVirtualKey{ @@ -6563,7 +6531,6 @@ func TestGenerateVirtualKeyHash(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &teamID, - BudgetID: &budgetID, RateLimitID: &rateLimitID, // RateLimitID set } @@ -6585,7 +6552,6 @@ func TestGenerateVirtualKeyHash(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &teamID, - BudgetID: &budgetID, RateLimitID: &differentRateLimitID, // Different RateLimitID } @@ -6603,7 +6569,6 @@ func TestGenerateVirtualKeyHash(t *testing.T) { // TestGenerateVirtualKeyHash_WithProviderConfigs tests hash generation with provider configs func TestGenerateVirtualKeyHash_WithProviderConfigs(t *testing.T) { - budgetID := "budget-pc-1" rateLimitID := "rl-pc-1" // Virtual key with provider configs @@ -6620,7 +6585,6 @@ func TestGenerateVirtualKeyHash_WithProviderConfigs(t *testing.T) { Provider: "openai", Weight: ptrFloat64(1.0), AllowedModels: []string{"gpt-4", "gpt-3.5-turbo"}, - BudgetID: &budgetID, RateLimitID: &rateLimitID, Keys: []tables.TableKey{ {KeyID: "key-1", Name: "key-1"}, @@ -6653,7 +6617,6 @@ func TestGenerateVirtualKeyHash_WithProviderConfigs(t *testing.T) { Provider: "anthropic", // Different provider Weight: ptrFloat64(1.0), AllowedModels: []string{"claude-3"}, - BudgetID: &budgetID, RateLimitID: &rateLimitID, }, }, @@ -6682,7 +6645,6 @@ func TestGenerateVirtualKeyHash_WithProviderConfigs(t *testing.T) { Provider: "openai", Weight: ptrFloat64(2.0), // Different weight AllowedModels: []string{"gpt-4", "gpt-3.5-turbo"}, - BudgetID: &budgetID, RateLimitID: &rateLimitID, Keys: []tables.TableKey{ {KeyID: "key-1", Name: "key-1"}, @@ -6786,7 +6748,6 @@ func TestGenerateVirtualKeyHash_WithMCPConfigs(t *testing.T) { // TestVirtualKeyHashComparison_MatchingHash tests that DB config is kept when hashes match func TestVirtualKeyHashComparison_MatchingHash(t *testing.T) { teamID := "team-1" - budgetID := "budget-1" // Create a virtual key (simulating what's in config.json) fileVK := tables.TableVirtualKey{ @@ -6796,7 +6757,6 @@ func TestVirtualKeyHashComparison_MatchingHash(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &teamID, - BudgetID: &budgetID, } // Generate file hash @@ -6807,7 +6767,6 @@ func TestVirtualKeyHashComparison_MatchingHash(t *testing.T) { // Create DB virtual key with same content (simulating existing DB record) dbTeamID := "team-1" - dbBudgetID := "budget-1" dbVK := tables.TableVirtualKey{ ID: "vk-1", Name: "test-vk", @@ -6815,7 +6774,6 @@ func TestVirtualKeyHashComparison_MatchingHash(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &dbTeamID, - BudgetID: &dbBudgetID, ConfigHash: fileHash, // Same hash as file } @@ -6840,7 +6798,6 @@ func TestVirtualKeyHashComparison_MatchingHash(t *testing.T) { // TestVirtualKeyHashComparison_DifferentHash tests that file config is used when hashes differ func TestVirtualKeyHashComparison_DifferentHash(t *testing.T) { teamID := "team-1" - budgetID := "budget-1" // Create DB virtual key with old config dbVK := tables.TableVirtualKey{ @@ -6850,7 +6807,6 @@ func TestVirtualKeyHashComparison_DifferentHash(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &teamID, - BudgetID: &budgetID, } dbHash, err := configstore.GenerateVirtualKeyHash(dbVK) @@ -6861,7 +6817,6 @@ func TestVirtualKeyHashComparison_DifferentHash(t *testing.T) { // Create file virtual key with updated config fileTeamID := "team-1" - fileBudgetID := "budget-1" fileVK := tables.TableVirtualKey{ ID: "vk-1", Name: "new-name", // Updated name @@ -6869,7 +6824,6 @@ func TestVirtualKeyHashComparison_DifferentHash(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &fileTeamID, - BudgetID: &fileBudgetID, } fileHash, err := configstore.GenerateVirtualKeyHash(fileVK) @@ -7041,26 +6995,6 @@ func TestVirtualKeyHashComparison_OptionalFieldsPresence(t *testing.T) { t.Error("Expected different hash for team_id vs customer_id") } - // Virtual key with budget_id - budgetID := "budget-1" - vkWithBudget := tables.TableVirtualKey{ - ID: "vk-1", - Name: "test-vk", - Description: "", - Value: "vk_abc123", - IsActive: true, - BudgetID: &budgetID, - } - - hashWithBudget, err := configstore.GenerateVirtualKeyHash(vkWithBudget) - if err != nil { - t.Fatalf("Failed to generate hash: %v", err) - } - - if hashNoOptional == hashWithBudget { - t.Error("Expected different hash when budget_id is added") - } - // Virtual key with rate_limit_id rateLimitID := "rl-1" vkWithRateLimit := tables.TableVirtualKey{ @@ -7087,7 +7021,6 @@ func TestVirtualKeyHashComparison_OptionalFieldsPresence(t *testing.T) { // TestVirtualKeyHashComparison_FieldValueChanges tests hash changes when field values change func TestVirtualKeyHashComparison_FieldValueChanges(t *testing.T) { teamID := "team-1" - budgetID := "budget-1" // Base virtual key baseVK := tables.TableVirtualKey{ @@ -7097,7 +7030,6 @@ func TestVirtualKeyHashComparison_FieldValueChanges(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &teamID, - BudgetID: &budgetID, } baseHash, err := configstore.GenerateVirtualKeyHash(baseVK) @@ -7145,27 +7077,12 @@ func TestVirtualKeyHashComparison_FieldValueChanges(t *testing.T) { t.Error("Expected different hash when TeamID value changes") } - // Change BudgetID value - newBudgetID := "budget-2" - vkChangedBudget := baseVK - vkChangedBudget.BudgetID = &newBudgetID - - hashChangedBudget, err := configstore.GenerateVirtualKeyHash(vkChangedBudget) - if err != nil { - t.Fatalf("Failed to generate hash: %v", err) - } - - if baseHash == hashChangedBudget { - t.Error("Expected different hash when BudgetID value changes") - } - t.Log("✓ Field value changes correctly detected in hash") } // TestVirtualKeyHashComparison_RoundTrip tests JSON → DB → same JSON produces no changes func TestVirtualKeyHashComparison_RoundTrip(t *testing.T) { teamID := "team-1" - budgetID := "budget-1" rateLimitID := "rl-1" // Original config.json virtual key @@ -7176,7 +7093,6 @@ func TestVirtualKeyHashComparison_RoundTrip(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &teamID, - BudgetID: &budgetID, RateLimitID: &rateLimitID, ProviderConfigs: []tables.TableVirtualKeyProviderConfig{ { @@ -7199,7 +7115,6 @@ func TestVirtualKeyHashComparison_RoundTrip(t *testing.T) { // Same config.json on reload (simulating app restart) reloadTeamID := "team-1" - reloadBudgetID := "budget-1" reloadRateLimitID := "rl-1" reloadVK := tables.TableVirtualKey{ ID: "vk-1", @@ -7208,7 +7123,6 @@ func TestVirtualKeyHashComparison_RoundTrip(t *testing.T) { Value: "vk_abc123", IsActive: true, TeamID: &reloadTeamID, - BudgetID: &reloadBudgetID, RateLimitID: &reloadRateLimitID, ProviderConfigs: []tables.TableVirtualKeyProviderConfig{ { @@ -8720,7 +8634,6 @@ func TestSQLite_FullLifecycle_InitialLoad(t *testing.T) { Description: "Test virtual key 1", Value: "vk_test123", IsActive: true, - BudgetID: &budgetID, RateLimitID: &rateLimitID, ProviderConfigs: []tables.TableVirtualKeyProviderConfig{ { @@ -10435,9 +10348,6 @@ func TestGenerateKeyHash_StableOrdering(t *testing.T) { // TestGenerateVirtualKeyHash_StableProviderConfigOrdering verifies hash stability with different provider config orderings func TestGenerateVirtualKeyHash_StableProviderConfigOrdering(t *testing.T) { - budgetID1 := "budget-1" - budgetID2 := "budget-2" - // VK with provider configs in order A vkOrderA := tables.TableVirtualKey{ ID: "vk-1", @@ -10452,7 +10362,6 @@ func TestGenerateVirtualKeyHash_StableProviderConfigOrdering(t *testing.T) { Provider: "openai", Weight: ptrFloat64(1.0), AllowedModels: []string{"gpt-4"}, - BudgetID: &budgetID1, }, { ID: 2, @@ -10460,7 +10369,6 @@ func TestGenerateVirtualKeyHash_StableProviderConfigOrdering(t *testing.T) { Provider: "anthropic", Weight: ptrFloat64(2.0), AllowedModels: []string{"claude-3"}, - BudgetID: &budgetID2, }, { ID: 3, @@ -10493,7 +10401,6 @@ func TestGenerateVirtualKeyHash_StableProviderConfigOrdering(t *testing.T) { Provider: "anthropic", Weight: ptrFloat64(2.0), AllowedModels: []string{"claude-3"}, - BudgetID: &budgetID2, }, { ID: 1, @@ -10501,7 +10408,6 @@ func TestGenerateVirtualKeyHash_StableProviderConfigOrdering(t *testing.T) { Provider: "openai", Weight: ptrFloat64(1.0), AllowedModels: []string{"gpt-4"}, - BudgetID: &budgetID1, }, }, } @@ -10520,7 +10426,6 @@ func TestGenerateVirtualKeyHash_StableProviderConfigOrdering(t *testing.T) { Provider: "anthropic", Weight: ptrFloat64(2.0), AllowedModels: []string{"claude-3"}, - BudgetID: &budgetID2, }, { ID: 1, @@ -10528,7 +10433,6 @@ func TestGenerateVirtualKeyHash_StableProviderConfigOrdering(t *testing.T) { Provider: "openai", Weight: ptrFloat64(1.0), AllowedModels: []string{"gpt-4"}, - BudgetID: &budgetID1, }, { ID: 3, @@ -10941,8 +10845,6 @@ func TestGenerateVirtualKeyHash_StableToolsToExecuteOrdering(t *testing.T) { // TestGenerateVirtualKeyHash_StableCombinedOrdering verifies hash stability with all nested orderings randomized func TestGenerateVirtualKeyHash_StableCombinedOrdering(t *testing.T) { - budgetID := "budget-1" - // VK with all elements in order A vkOrderA := tables.TableVirtualKey{ ID: "vk-1", @@ -10950,7 +10852,6 @@ func TestGenerateVirtualKeyHash_StableCombinedOrdering(t *testing.T) { Description: "Test virtual key", Value: "vk_abc123", IsActive: true, - BudgetID: &budgetID, ProviderConfigs: []tables.TableVirtualKeyProviderConfig{ { ID: 1, @@ -10993,7 +10894,6 @@ func TestGenerateVirtualKeyHash_StableCombinedOrdering(t *testing.T) { Description: "Test virtual key", Value: "vk_abc123", IsActive: true, - BudgetID: &budgetID, ProviderConfigs: []tables.TableVirtualKeyProviderConfig{ { ID: 2, @@ -14081,11 +13981,9 @@ func TestSQLite_Key_UseForBatchAPIChange_Detected(t *testing.T) { } } -// TestGenerateVirtualKeyHash_ProviderConfigBudgetRateLimit verifies that BudgetID and RateLimitID -// in VK provider configs affect hash generation. -func TestGenerateVirtualKeyHash_ProviderConfigBudgetRateLimit(t *testing.T) { - budgetID1 := "budget-1" - budgetID2 := "budget-2" +// TestGenerateVirtualKeyHash_ProviderConfigRateLimit verifies that RateLimitID +// in VK provider configs affects hash generation. +func TestGenerateVirtualKeyHash_ProviderConfigRateLimit(t *testing.T) { rateLimitID1 := "rate-limit-1" rateLimitID2 := "rate-limit-2" weight := 1.0 @@ -14096,34 +13994,6 @@ func TestGenerateVirtualKeyHash_ProviderConfigBudgetRateLimit(t *testing.T) { vk2 tables.TableVirtualKey expectEqual bool }{ - { - name: "different_budget_id_different_hash", - vk1: tables.TableVirtualKey{ - ID: "vk-1", - Name: "test-vk", - IsActive: true, - ProviderConfigs: []tables.TableVirtualKeyProviderConfig{ - { - Provider: "openai", - Weight: &weight, - BudgetID: &budgetID1, - }, - }, - }, - vk2: tables.TableVirtualKey{ - ID: "vk-1", - Name: "test-vk", - IsActive: true, - ProviderConfigs: []tables.TableVirtualKeyProviderConfig{ - { - Provider: "openai", - Weight: &weight, - BudgetID: &budgetID2, - }, - }, - }, - expectEqual: false, - }, { name: "different_rate_limit_id_different_hash", vk1: tables.TableVirtualKey{ @@ -14152,34 +14022,6 @@ func TestGenerateVirtualKeyHash_ProviderConfigBudgetRateLimit(t *testing.T) { }, expectEqual: false, }, - { - name: "nil_vs_set_budget_id_different_hash", - vk1: tables.TableVirtualKey{ - ID: "vk-1", - Name: "test-vk", - IsActive: true, - ProviderConfigs: []tables.TableVirtualKeyProviderConfig{ - { - Provider: "openai", - Weight: &weight, - BudgetID: nil, - }, - }, - }, - vk2: tables.TableVirtualKey{ - ID: "vk-1", - Name: "test-vk", - IsActive: true, - ProviderConfigs: []tables.TableVirtualKeyProviderConfig{ - { - Provider: "openai", - Weight: &weight, - BudgetID: &budgetID1, - }, - }, - }, - expectEqual: false, - }, { name: "nil_vs_set_rate_limit_id_different_hash", vk1: tables.TableVirtualKey{ @@ -14209,7 +14051,7 @@ func TestGenerateVirtualKeyHash_ProviderConfigBudgetRateLimit(t *testing.T) { expectEqual: false, }, { - name: "same_budget_and_rate_limit_same_hash", + name: "same_rate_limit_same_hash", vk1: tables.TableVirtualKey{ ID: "vk-1", Name: "test-vk", @@ -14218,7 +14060,6 @@ func TestGenerateVirtualKeyHash_ProviderConfigBudgetRateLimit(t *testing.T) { { Provider: "openai", Weight: &weight, - BudgetID: &budgetID1, RateLimitID: &rateLimitID1, }, }, @@ -14231,7 +14072,6 @@ func TestGenerateVirtualKeyHash_ProviderConfigBudgetRateLimit(t *testing.T) { { Provider: "openai", Weight: &weight, - BudgetID: &budgetID1, RateLimitID: &rateLimitID1, }, }, @@ -14262,107 +14102,6 @@ func TestGenerateVirtualKeyHash_ProviderConfigBudgetRateLimit(t *testing.T) { } } -// TestSQLite_VKProviderConfig_BudgetAndRateLimit verifies that BudgetID and RateLimitID -// in VK provider configs are properly persisted and retrieved from SQLite. -func TestSQLite_VKProviderConfig_BudgetAndRateLimit(t *testing.T) { - initTestLogger() - tempDir := createTempDir(t) - - budgetID := "budget-123" - rateLimitID := "rate-limit-456" - vkID := uuid.NewString() - weight := 1.0 - - // Create config with VK that has provider config with BudgetID and RateLimitID - configData := makeConfigDataFullWithDir( - nil, - map[string]configstore.ProviderConfig{ - "openai": { - Keys: []schemas.Key{ - { - ID: uuid.NewString(), - Name: "openai-key", - Value: *schemas.NewEnvVar("sk-test"), - Weight: 1, - }, - }, - }, - }, - &configstore.GovernanceConfig{ - Budgets: []tables.TableBudget{ - { - ID: budgetID, - MaxLimit: 100.0, - }, - }, - RateLimits: []tables.TableRateLimit{ - { - ID: rateLimitID, - RequestMaxLimit: int64Ptr(60), - TokenMaxLimit: int64Ptr(10000), - }, - }, - VirtualKeys: []tables.TableVirtualKey{ - { - ID: vkID, - Name: "test-vk", - Value: "vk-test-value", - IsActive: true, - ProviderConfigs: []tables.TableVirtualKeyProviderConfig{ - { - Provider: "openai", - Weight: &weight, - BudgetID: &budgetID, - RateLimitID: &rateLimitID, - }, - }, - }, - }, - }, - tempDir, - ) - - // Load config - createConfigFile(t, tempDir, configData) - config, err := LoadConfig(context.Background(), tempDir) - if err != nil { - t.Fatalf("LoadConfig failed: %v", err) - } - defer config.Close(context.Background()) - - // Verify the governance config has the VK with provider configs - if config.GovernanceConfig == nil { - t.Fatal("Expected GovernanceConfig to exist") - } - if len(config.GovernanceConfig.VirtualKeys) == 0 { - t.Fatal("Expected VirtualKeys in GovernanceConfig") - } - - // Find the VK and verify provider config - var foundVK *tables.TableVirtualKey - for i := range config.GovernanceConfig.VirtualKeys { - if config.GovernanceConfig.VirtualKeys[i].ID == vkID { - foundVK = &config.GovernanceConfig.VirtualKeys[i] - break - } - } - if foundVK == nil { - t.Fatalf("Virtual key %s not found in config", vkID) - } - - if len(foundVK.ProviderConfigs) == 0 { - t.Fatal("Expected VK to have provider configs") - } - - pc := foundVK.ProviderConfigs[0] - if pc.BudgetID == nil || *pc.BudgetID != budgetID { - t.Errorf("Expected BudgetID=%s, got %v", budgetID, pc.BudgetID) - } - if pc.RateLimitID == nil || *pc.RateLimitID != rateLimitID { - t.Errorf("Expected RateLimitID=%s, got %v", rateLimitID, pc.RateLimitID) - } -} - // intPtr is a helper to create a pointer to an int func intPtr(i int) *int { return &i @@ -15378,9 +15117,11 @@ var excludedGoFields = map[string]map[string]bool{ }, // Table types have DB-specific fields "tables.TableBudget": { - "config_hash": true, - "created_at": true, - "updated_at": true, + "config_hash": true, + "created_at": true, + "updated_at": true, + "virtual_key_id": true, // Internal DB FK for multi-budget ownership + "provider_config_id": true, // Internal DB FK for multi-budget ownership }, "tables.TableRateLimit": { "config_hash": true, @@ -15409,16 +15150,16 @@ var excludedGoFields = map[string]map[string]bool{ "config_hash": true, "created_at": true, "updated_at": true, - "budget": true, // GORM relation + "budgets": true, // GORM relation (budgets have virtual_key_id FK) "rate_limit": true, // GORM relation "team": true, // GORM relation "customer": true, // GORM relation }, "tables.TableVirtualKeyProviderConfig": { - "budget": true, // GORM relation - "rate_limit": true, // GORM relation + "rate_limit": true, // GORM relation "allow_all_keys": true, // Internal DB field; users configure via key_ids - "keys": true, // GORM many2many relation; users configure via key_ids + "keys": true, // GORM many2many relation; users configure via key_ids + "budgets": true, // GORM relation (budgets have provider_config_id FK) }, "tables.TableVirtualKeyMCPConfig": { "mcp_client": true, // GORM relation diff --git a/transports/config.schema.json b/transports/config.schema.json index df417ddd47..bc8b267f15 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -165,7 +165,10 @@ }, "mcp_code_mode_binding_level": { "type": "string", - "enum": ["server", "tool"], + "enum": [ + "server", + "tool" + ], "description": "Code mode binding level for MCP tools" }, "mcp_tool_sync_interval": { @@ -291,6 +294,14 @@ "type": "boolean", "description": "Snap resets to calendar boundaries (day/week/month/year start)", "default": false + }, + "virtual_key_id": { + "type": "string", + "description": "ID of the virtual key this budget belongs to (mutually exclusive with provider_config_id)" + }, + "provider_config_id": { + "type": "integer", + "description": "ID of the provider config this budget belongs to (mutually exclusive with virtual_key_id)" } }, "required": [ @@ -457,6 +468,11 @@ "description": "Whether the virtual key is active", "default": true }, + "calendar_aligned": { + "type": "boolean", + "description": "Snap all budget resets to calendar boundaries (day, week, month, year)", + "default": false + }, "team_id": { "type": "string", "description": "Associated team ID (mutually exclusive with customer_id)" @@ -465,10 +481,6 @@ "type": "string", "description": "Associated customer ID (mutually exclusive with team_id)" }, - "budget_id": { - "type": "string", - "description": "Associated budget ID" - }, "rate_limit_id": { "type": "string", "description": "Associated rate limit ID" @@ -577,7 +589,9 @@ "description": "Capture raw request/response for internal logging only; strip from API responses returned to clients (default: false)" } }, - "required": ["name"] + "required": [ + "name" + ] } } }, @@ -932,7 +946,10 @@ }, "placement": { "type": "string", - "enum": ["pre_builtin", "post_builtin"], + "enum": [ + "pre_builtin", + "post_builtin" + ], "description": "Whether this plugin runs before or after built-in plugins. Default: post_builtin", "optional": true, "default": "post_builtin" @@ -1012,10 +1029,15 @@ "description": "Password for basic authentication" } }, - "required": ["username", "password"] + "required": [ + "username", + "password" + ] } }, - "required": ["push_gateway_url"] + "required": [ + "push_gateway_url" + ] } }, "additionalProperties": false @@ -1474,7 +1496,9 @@ "maximum": 1 } }, - "required": ["weight"], + "required": [ + "weight" + ], "additionalProperties": false }, "routing_rule": { @@ -1521,12 +1545,17 @@ }, "scope": { "type": "string", - "enum": ["global", "team", "customer", "virtual_key"], + "enum": [ + "global", + "team", + "customer", + "virtual_key" + ], "description": "Rule scope level", "default": "global" }, "scope_id": { - "type": ["string", "null"], + "type": "string", "description": "Entity ID for non-global scopes (required for non-global scope)" }, "priority": { @@ -1540,8 +1569,37 @@ "additionalProperties": true } }, - "required": ["id", "name", "targets"], - "additionalProperties": false + "required": [ + "id", + "name", + "targets" + ], + "additionalProperties": false, + "if": { + "properties": { + "scope": { + "enum": [ + "team", + "customer", + "virtual_key" + ] + } + }, + "required": [ + "scope" + ] + }, + "then": { + "required": [ + "scope_id" + ], + "properties": { + "scope_id": { + "type": "string", + "minLength": 1 + } + } + } }, "virtual_key_provider_config": { "type": "object", @@ -1560,7 +1618,10 @@ "description": "Provider name" }, "weight": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "description": "Weight for load balancing (null opts out of weighted routing)", "default": null }, @@ -1571,10 +1632,6 @@ "type": "string" } }, - "budget_id": { - "type": "string", - "description": "Associated budget ID" - }, "rate_limit_id": { "type": "string", "description": "Associated rate limit ID" @@ -2486,7 +2543,11 @@ }, "auth_type": { "type": "string", - "enum": ["none", "headers", "oauth"], + "enum": [ + "none", + "headers", + "oauth" + ], "description": "Authentication type for MCP connection" }, "oauth_config_id": { @@ -2632,8 +2693,16 @@ } }, "anyOf": [ - { "required": ["http_config"] }, - { "required": ["connection_string"] } + { + "required": [ + "http_config" + ] + }, + { + "required": [ + "connection_string" + ] + } ] }, { @@ -2665,7 +2734,10 @@ }, "code_mode_binding_level": { "type": "string", - "enum": ["server", "tool"], + "enum": [ + "server", + "tool" + ], "description": "How tools are exposed in VFS for code execution" }, "disable_auto_tool_inject": { @@ -3148,7 +3220,11 @@ }, "cloud": { "type": "string", - "enum": ["commercial", "gcc-high", "dod"], + "enum": [ + "commercial", + "gcc-high", + "dod" + ], "default": "commercial", "description": "Cloud environment: 'commercial' (default), 'gcc-high' for US Government GCC High, or 'dod' for Department of Defense" }, @@ -3412,7 +3488,14 @@ "scope_kind": { "type": "string", "description": "Scope level for this override", - "enum": ["global", "provider", "provider_key", "virtual_key", "virtual_key_provider", "virtual_key_provider_key"] + "enum": [ + "global", + "provider", + "provider_key", + "virtual_key", + "virtual_key_provider", + "virtual_key_provider_key" + ] }, "virtual_key_id": { "type": "string", @@ -3429,7 +3512,10 @@ "match_type": { "type": "string", "description": "How the pattern is matched against model names", - "enum": ["exact", "wildcard"] + "enum": [ + "exact", + "wildcard" + ] }, "pattern": { "type": "string", @@ -3452,21 +3538,38 @@ "description": "Internal hash for change detection (auto-managed)" } }, - "required": ["id", "name", "scope_kind", "match_type", "pattern", "request_types"], + "required": [ + "id", + "name", + "scope_kind", + "match_type", + "pattern", + "request_types" + ], "additionalProperties": false }, "pricing_override_match_type": { "type": "string", - "enum": ["exact", "wildcard"] + "enum": [ + "exact", + "wildcard" + ] }, "pricing_override_request_type": { "type": "string", "enum": [ - "chat_completion", "text_completion", "responses", - "embedding", "rerank", - "speech", "transcription", - "image_generation", "image_variation", "image_edit", - "video_generation", "video_remix" + "chat_completion", + "text_completion", + "responses", + "embedding", + "rerank", + "speech", + "transcription", + "image_generation", + "image_variation", + "image_edit", + "video_generation", + "video_remix" ] }, "custom_provider_config": { @@ -3485,65 +3588,167 @@ "type": "object", "description": "Allowed request types for the custom provider", "properties": { - "list_models": { "type": "boolean" }, - "text_completion": { "type": "boolean" }, - "text_completion_stream": { "type": "boolean" }, - "chat_completion": { "type": "boolean" }, - "chat_completion_stream": { "type": "boolean" }, - "responses": { "type": "boolean" }, - "responses_stream": { "type": "boolean" }, - "count_tokens": { "type": "boolean" }, - "embedding": { "type": "boolean" }, - "rerank": { "type": "boolean" }, - "speech": { "type": "boolean" }, - "speech_stream": { "type": "boolean" }, - "transcription": { "type": "boolean" }, - "transcription_stream": { "type": "boolean" }, - "image_generation": { "type": "boolean" }, - "image_generation_stream": { "type": "boolean" }, - "image_edit": { "type": "boolean" }, - "image_edit_stream": { "type": "boolean" }, - "image_variation": { "type": "boolean" }, - "video_generation": { "type": "boolean" }, - "video_retrieve": { "type": "boolean" }, - "video_download": { "type": "boolean" }, - "video_delete": { "type": "boolean" }, - "video_list": { "type": "boolean" }, - "video_remix": { "type": "boolean" }, - "batch_create": { "type": "boolean" }, - "batch_list": { "type": "boolean" }, - "batch_retrieve": { "type": "boolean" }, - "batch_cancel": { "type": "boolean" }, - "batch_delete": { "type": "boolean" }, - "batch_results": { "type": "boolean" }, - "file_upload": { "type": "boolean" }, - "file_list": { "type": "boolean" }, - "file_retrieve": { "type": "boolean" }, - "file_delete": { "type": "boolean" }, - "file_content": { "type": "boolean" }, - "container_create": { "type": "boolean" }, - "container_list": { "type": "boolean" }, - "container_retrieve": { "type": "boolean" }, - "container_delete": { "type": "boolean" }, - "container_file_create": { "type": "boolean" }, - "container_file_list": { "type": "boolean" }, - "container_file_retrieve": { "type": "boolean" }, - "container_file_content": { "type": "boolean" }, - "container_file_delete": { "type": "boolean" }, - "passthrough": { "type": "boolean" }, - "passthrough_stream": { "type": "boolean" }, - "websocket_responses": { "type": "boolean" }, - "realtime": { "type": "boolean" } + "list_models": { + "type": "boolean" + }, + "text_completion": { + "type": "boolean" + }, + "text_completion_stream": { + "type": "boolean" + }, + "chat_completion": { + "type": "boolean" + }, + "chat_completion_stream": { + "type": "boolean" + }, + "responses": { + "type": "boolean" + }, + "responses_stream": { + "type": "boolean" + }, + "count_tokens": { + "type": "boolean" + }, + "embedding": { + "type": "boolean" + }, + "rerank": { + "type": "boolean" + }, + "speech": { + "type": "boolean" + }, + "speech_stream": { + "type": "boolean" + }, + "transcription": { + "type": "boolean" + }, + "transcription_stream": { + "type": "boolean" + }, + "image_generation": { + "type": "boolean" + }, + "image_generation_stream": { + "type": "boolean" + }, + "image_edit": { + "type": "boolean" + }, + "image_edit_stream": { + "type": "boolean" + }, + "image_variation": { + "type": "boolean" + }, + "video_generation": { + "type": "boolean" + }, + "video_retrieve": { + "type": "boolean" + }, + "video_download": { + "type": "boolean" + }, + "video_delete": { + "type": "boolean" + }, + "video_list": { + "type": "boolean" + }, + "video_remix": { + "type": "boolean" + }, + "batch_create": { + "type": "boolean" + }, + "batch_list": { + "type": "boolean" + }, + "batch_retrieve": { + "type": "boolean" + }, + "batch_cancel": { + "type": "boolean" + }, + "batch_delete": { + "type": "boolean" + }, + "batch_results": { + "type": "boolean" + }, + "file_upload": { + "type": "boolean" + }, + "file_list": { + "type": "boolean" + }, + "file_retrieve": { + "type": "boolean" + }, + "file_delete": { + "type": "boolean" + }, + "file_content": { + "type": "boolean" + }, + "container_create": { + "type": "boolean" + }, + "container_list": { + "type": "boolean" + }, + "container_retrieve": { + "type": "boolean" + }, + "container_delete": { + "type": "boolean" + }, + "container_file_create": { + "type": "boolean" + }, + "container_file_list": { + "type": "boolean" + }, + "container_file_retrieve": { + "type": "boolean" + }, + "container_file_content": { + "type": "boolean" + }, + "container_file_delete": { + "type": "boolean" + }, + "passthrough": { + "type": "boolean" + }, + "passthrough_stream": { + "type": "boolean" + }, + "websocket_responses": { + "type": "boolean" + }, + "realtime": { + "type": "boolean" + } }, "additionalProperties": false }, "request_path_overrides": { "type": "object", "description": "Mapping of request type to custom path overriding the default provider path", - "additionalProperties": { "type": "string" } + "additionalProperties": { + "type": "string" + } } }, - "required": ["base_provider_type"], + "required": [ + "base_provider_type" + ], "additionalProperties": false } } diff --git a/ui/app/_fallbacks/enterprise/components/access-profiles/accessProfilesIndexView.tsx b/ui/app/_fallbacks/enterprise/components/access-profiles/accessProfilesIndexView.tsx new file mode 100644 index 0000000000..e7747a2742 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/access-profiles/accessProfilesIndexView.tsx @@ -0,0 +1,17 @@ +import { ShieldCheck } from "lucide-react"; +import ContactUsView from "../views/contactUsView"; + +export default function AccessProfilesIndexView() { + return ( +
+ } + title="Unlock access profiles for better performance" + description="This feature is a part of the Bifrost enterprise license. Create access profiles to control access to your resources." + readmeLink="https://docs.getbifrost.ai/enterprise/access-profiles" + testIdPrefix="access-profiles" + /> +
+ ); +} diff --git a/ui/app/_fallbacks/enterprise/components/user-groups/businessUnitsView.tsx b/ui/app/_fallbacks/enterprise/components/user-groups/businessUnitsView.tsx new file mode 100644 index 0000000000..57ad4e35d5 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/user-groups/businessUnitsView.tsx @@ -0,0 +1,17 @@ +import { Users } from "lucide-react"; +import ContactUsView from "../views/contactUsView"; + +export function BusinessUnitsView() { + return ( +
+ } + title="Unlock advanced governance" + description="Manage users, business units with our enterprise-grade governance. This feature is part of the Bifrost enterprise license." + readmeLink="https://docs.getbifrost.ai/enterprise/advanced-governance" + /> +
+ ); +} diff --git a/ui/app/_fallbacks/enterprise/components/user-groups/teamsView.tsx b/ui/app/_fallbacks/enterprise/components/user-groups/teamsView.tsx new file mode 100644 index 0000000000..ecf9fa7fb4 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/components/user-groups/teamsView.tsx @@ -0,0 +1,17 @@ +import { Users } from "lucide-react"; +import ContactUsView from "../views/contactUsView"; + +export function TeamsView() { + return ( +
+ } + title="Unlock teams governance" + description="Manage teams, sync from your identity provider, and control access with enterprise-grade governance. This feature is part of the Bifrost enterprise license." + readmeLink="https://docs.getbifrost.ai/enterprise/advanced-governance" + /> +
+ ) +} diff --git a/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx b/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx index 36f895a281..0f00037bf7 100644 --- a/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx +++ b/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx @@ -27,6 +27,7 @@ export enum RbacResource { PIIRedactor = "PIIRedactor", PromptRepository = "PromptRepository", PromptDeploymentStrategy = "PromptDeploymentStrategy", + AccessProfiles = "AccessProfiles", } // RBAC Operation Names (must match backend definitions) diff --git a/ui/app/workspace/dashboard/components/charts/modelFilterSelect.tsx b/ui/app/workspace/dashboard/components/charts/modelFilterSelect.tsx index fe8d459ac1..c605182525 100644 --- a/ui/app/workspace/dashboard/components/charts/modelFilterSelect.tsx +++ b/ui/app/workspace/dashboard/components/charts/modelFilterSelect.tsx @@ -10,10 +10,16 @@ interface ModelFilterSelectProps { "data-testid"?: string; } -export function ModelFilterSelect({ models, selectedModel, onModelChange, placeholder = "All Models", "data-testid": testId }: ModelFilterSelectProps) { +export function ModelFilterSelect({ + models, + selectedModel, + onModelChange, + placeholder = "All Models", + "data-testid": testId, +}: ModelFilterSelectProps) { return (