From 85fa16651bae5a4e89792faa5e7a947360553fd9 Mon Sep 17 00:00:00 2001 From: Vaibhav Mittal Date: Fri, 22 May 2026 00:55:55 +0530 Subject: [PATCH 1/5] feat(governance): add virtual key blocked models (#3653) Adds blocked model support for virtual key provider configurations. Provider keys already supported both allowed and blocked models, but virtual key provider configs only supported allowed models. This PR adds the missing blocked model flow for virtual keys, so specific models can be denied at the VK provider-config level. * Added `blacklisted_models` to virtual key provider configs. * Added a configstore migration for the new `blacklisted_models` column. * Updated virtual key create/update handlers to validate, persist, and return blocked models. * Added governance checks to reject virtual key requests when the requested model is blocked. * Made blocked models take priority over allowed models. * Updated virtual key model filtering to respect blocked models. * Added `Blocked Models` UI under provider configurations in the virtual key create/edit sheet. * Added blocked model display in the virtual key details sheet. * Updated frontend governance types for virtual key provider configs. This follows the existing provider-key blocked model behavior instead of introducing a separate flow. Behavior: * Empty `blacklisted_models` means no models are blocked. * `["*"]` blocks all models for that VK provider config. * If the same model exists in both allowed and blocked models, the blocked list wins. * [ ] Bug fix * [x] Feature * [ ] Refactor * [ ] Documentation * [ ] Chore/CI * [x] Core (Go) * [x] Transports (HTTP) * [ ] Providers/Integrations * [x] Plugins * [x] UI (React) * [ ] Docs Local UI flow: Start Bifrost locally with embedded UI on port `9090`, then open: `http://localhost:9090/workspace/governance/virtual-keys` Steps tested: 1. Open Virtual Keys. 2. Create or edit a virtual key. 3. Expand a provider config. 4. Confirm `Blocked Models` appears below `Allowed Models`. 5. Select a blocked model and save. 6. Reopen the virtual key and confirm the blocked model is still shown. 7. Refresh the page and confirm the value persists. Runtime validation: 1. Configure a VK provider config with `allowed_models: ["*"]` and `blacklisted_models: [""]`. 2. Send a request through that virtual key using the blocked model. Expected: request is rejected. 3. Send a request through the same virtual key using a model not present in `blacklisted_models`. Expected: request succeeds. 4. Configure the same model in both `allowed_models` and `blacklisted_models`. Expected: request is rejected because blocked models take priority. 5. Configure `blacklisted_models: []`. Expected: existing virtual key behavior remains unchanged. Sanity checks: `go test ./framework/configstore/... ./plugins/governance/... ./transports/bifrost-http/handlers/...` UI check: `cd ui && pnpm build` Added a recording showing the new `Blocked Models` field in the virtual key provider configuration flow. https://github.com/user-attachments/assets/64efca01-0366-491c-b9e7-95dfa08eb0bc * [ ] Yes * [x] No BF-896 This change improves virtual-key governance by allowing specific models to be denied for a VK provider config. No provider secrets, customer keys, auth tokens, or PII are exposed or stored by this change. Existing provider-key behavior is unchanged. * [x] I read `docs/contributing/README.md` and followed the guidelines * [ ] I added/updated tests where appropriate * [ ] I updated documentation where needed * [x] I verified builds succeed (Go and UI) * [x] I verified the CI pipeline passes locally if applicable. --- .../views/virtualKeyDetailsSheet.tsx | 211 +++++++++++++----- 1 file changed, 158 insertions(+), 53 deletions(-) diff --git a/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx b/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx index 9418e17343..796a6fe3cb 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx @@ -22,7 +22,11 @@ import { ProviderLabels, ProviderName } from "@/lib/constants/logs"; import { VirtualKey } from "@/lib/types/governance"; import { cn } from "@/lib/utils"; import { supportsCalendarAlignment } from "@/lib/constants/governance"; -import { calculateUsagePercentage, formatCurrency, parseResetPeriod } from "@/lib/utils/governance"; +import { + calculateUsagePercentage, + formatCurrency, + parseResetPeriod, +} from "@/lib/utils/governance"; import ManagedVirtualKeyNotice from "@enterprise/components/access-profiles/managedVirtualKeyNotice"; import { formatDistanceToNow } from "date-fns"; import { Users } from "lucide-react"; @@ -49,12 +53,17 @@ function UsageLine({
- {format(current)} / {format(max)} + {format(current)} /{" "} + {format(max)} 80 ? "text-amber-500" : "text-muted-foreground", + exhausted + ? "text-red-500" + : pct > 80 + ? "text-amber-500" + : "text-muted-foreground", )} > {pct}% @@ -62,7 +71,10 @@ function UsageLine({
); @@ -73,7 +85,10 @@ interface VirtualKeyDetailSheetProps { onClose: () => void; } -export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKeyDetailSheetProps) { +export default function VirtualKeyDetailSheet({ + virtualKey, + onClose, +}: VirtualKeyDetailSheetProps) { const { assignedUsers, isManagedByProfile, @@ -101,10 +116,12 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe // Rate limits exhausted (displayRateLimit?.token_current_usage && displayRateLimit?.token_max_limit && - displayRateLimit.token_current_usage >= displayRateLimit.token_max_limit) || + displayRateLimit.token_current_usage >= + displayRateLimit.token_max_limit) || (displayRateLimit?.request_current_usage && displayRateLimit?.request_max_limit && - displayRateLimit.request_current_usage >= displayRateLimit.request_max_limit); + displayRateLimit.request_current_usage >= + displayRateLimit.request_max_limit); return ( @@ -112,7 +129,8 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe {virtualKey.name} - {virtualKey.description || "Virtual key details and usage information"} + {virtualKey.description || + "Virtual key details and usage information"} @@ -141,10 +159,18 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe
- {virtualKey.is_active ? (isExhausted ? "Exhausted" : "Active") : "Inactive"} + {virtualKey.is_active + ? isExhausted + ? "Exhausted" + : "Active" + : "Inactive"}
@@ -159,7 +185,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe
- Last Updated + + Last Updated +
{formatDistanceToNow(new Date(virtualKey.updated_at), { addSuffix: true, @@ -169,9 +197,15 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe {entityInfo.type !== "None" && (
- Assigned To + + Assigned To +
- + {entityInfo.type} {entityInfo.name} @@ -188,14 +222,18 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe

Provider Configurations

- {!virtualKey.provider_configs || virtualKey.provider_configs.length === 0 ? ( + {!virtualKey.provider_configs || + virtualKey.provider_configs.length === 0 ? ( No providers configured (deny-by-default) ) : (
{virtualKey.provider_configs.map((config, index) => ( -
+
{/* Provider Header */}
@@ -205,7 +243,8 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe className="h-5 w-5" /> - {ProviderLabels[config.provider as ProviderName] || config.provider} + {ProviderLabels[config.provider as ProviderName] || + config.provider}
@@ -224,10 +263,15 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe All Models - ) : config.allowed_models && config.allowed_models.length > 0 ? ( + ) : config.allowed_models && + config.allowed_models.length > 0 ? (
{config.allowed_models.map((model) => ( - + {model} ))} @@ -253,7 +297,11 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe config.blacklisted_models.length > 0 ? (
{config.blacklisted_models.map((model) => ( - + {model} ))} @@ -278,7 +326,11 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe ) : config.keys && config.keys.length > 0 ? (
{config.keys.map((key) => ( - + {key.name} ))} @@ -296,7 +348,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe <>
-

Provider Budgets

+

+ Provider Budgets +

{config.budgets.map((b, bIdx) => (
- Resets {parseResetPeriod(b.reset_duration)} + Resets{" "} + {parseResetPeriod(b.reset_duration)} {virtualKey.calendar_aligned && - supportsCalendarAlignment(b.reset_duration) && + supportsCalendarAlignment( + b.reset_duration, + ) && " (calendar)"} {b.last_reset ? ( Last reset{" "} - {formatDistanceToNow(new Date(b.last_reset), { - addSuffix: true, - })} + {formatDistanceToNow( + new Date(b.last_reset), + { + addSuffix: true, + }, + )} ) : null}
@@ -331,7 +391,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe <>
-

Provider Rate Limits

+

+ Provider Rate Limits +

{/* Token Limits */} {config.rate_limit.token_max_limit != null ? ( @@ -340,7 +402,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe TOKEN LIMITS n.toLocaleString()} /> @@ -348,11 +412,13 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe Resets{" "} {parseResetPeriod( - config.rate_limit.token_reset_duration || "", + config.rate_limit + .token_reset_duration || "", )} {virtualKey.calendar_aligned && supportsCalendarAlignment( - config.rate_limit.token_reset_duration || "", + config.rate_limit + .token_reset_duration || "", ) && " (calendar)"} @@ -360,7 +426,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe Last reset{" "} {formatDistanceToNow( - new Date(config.rate_limit.token_last_reset), + new Date( + config.rate_limit.token_last_reset, + ), { addSuffix: true }, )} @@ -376,7 +444,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe REQUEST LIMITS n.toLocaleString()} /> @@ -384,11 +454,13 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe Resets{" "} {parseResetPeriod( - config.rate_limit.request_reset_duration || "", + config.rate_limit + .request_reset_duration || "", )} {virtualKey.calendar_aligned && supportsCalendarAlignment( - config.rate_limit.request_reset_duration || "", + config.rate_limit + .request_reset_duration || "", ) && " (calendar)"} @@ -396,7 +468,10 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe Last reset{" "} {formatDistanceToNow( - new Date(config.rate_limit.request_last_reset), + new Date( + config.rate_limit + .request_last_reset, + ), { addSuffix: true }, )} @@ -427,7 +502,8 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe

MCP Client Configurations

- {!virtualKey.mcp_configs || virtualKey.mcp_configs.length === 0 ? ( + {!virtualKey.mcp_configs || + virtualKey.mcp_configs.length === 0 ? ( No MCP clients configured (deny-by-default) @@ -442,17 +518,26 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe {virtualKey.mcp_configs.map((config, index) => ( - - {config.mcp_client?.name || "Unknown Client"} + + + {config.mcp_client?.name || "Unknown Client"} + {config.tools_to_execute?.includes("*") ? ( All Tools - ) : config.tools_to_execute && config.tools_to_execute.length > 0 ? ( + ) : config.tools_to_execute && + config.tools_to_execute.length > 0 ? (
{config.tools_to_execute.map((tool) => ( - + {tool} ))} @@ -514,7 +599,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe ))}
) : ( -

No budget limits configured

+

+ No budget limits configured +

)}
@@ -542,17 +629,25 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe />
- Resets {parseResetPeriod(displayRateLimit.token_reset_duration || "")} + Resets{" "} + {parseResetPeriod( + displayRateLimit.token_reset_duration || "", + )} {virtualKey.calendar_aligned && - supportsCalendarAlignment(displayRateLimit.token_reset_duration || "") && + supportsCalendarAlignment( + displayRateLimit.token_reset_duration || "", + ) && " (calendar)"} {displayRateLimit.token_last_reset ? ( Last reset{" "} - {formatDistanceToNow(new Date(displayRateLimit.token_last_reset), { - addSuffix: true, - })} + {formatDistanceToNow( + new Date(displayRateLimit.token_last_reset), + { + addSuffix: true, + }, + )} ) : null}
@@ -570,7 +665,10 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe />
- Resets {parseResetPeriod(displayRateLimit.request_reset_duration || "")} + Resets{" "} + {parseResetPeriod( + displayRateLimit.request_reset_duration || "", + )} {virtualKey.calendar_aligned && supportsCalendarAlignment( displayRateLimit.request_reset_duration || "", @@ -580,9 +678,12 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe {displayRateLimit.request_last_reset ? ( Last reset{" "} - {formatDistanceToNow(new Date(displayRateLimit.request_last_reset), { - addSuffix: true, - })} + {formatDistanceToNow( + new Date(displayRateLimit.request_last_reset), + { + addSuffix: true, + }, + )} ) : null}
@@ -591,15 +692,19 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe {displayRateLimit.token_max_limit == null && displayRateLimit.request_max_limit == null && ( -

No rate limits configured

+

+ No rate limits configured +

)}
) : ( -

No rate limits configured

+

+ No rate limits configured +

)}
); -} \ No newline at end of file +} From dfa1255670df71b5a889c455c419d0c06ce4889c Mon Sep 17 00:00:00 2001 From: Akshay Deo Date: Fri, 22 May 2026 04:50:47 +0530 Subject: [PATCH 2/5] v1.5.4 cut (#3680) --- core/changelog.md | 7 +++++++ framework/changelog.md | 5 +++++ plugins/compat/changelog.md | 1 + plugins/governance/changelog.md | 2 ++ plugins/jsonparser/changelog.md | 1 + plugins/logging/changelog.md | 1 + plugins/maxim/changelog.md | 1 + plugins/mocker/changelog.md | 1 + plugins/otel/changelog.md | 1 + plugins/prompts/changelog.md | 1 + plugins/semanticcache/changelog.md | 1 + plugins/telemetry/changelog.md | 1 + transports/changelog.md | 22 ++++++++++++++++++++++ 13 files changed, 45 insertions(+) diff --git a/core/changelog.md b/core/changelog.md index e69de29bb2..4d430b099b 100644 --- a/core/changelog.md +++ b/core/changelog.md @@ -0,0 +1,7 @@ +- fix: idle timeout panic in the streaming idle-timeout reader +- fix: short-circuit `IdleTimeoutReader` reads when the connection is already closed (#3672) +- fix: preserve tool call stop reason in Anthropic streaming fallback (#3640) (thanks [@dicnunz](https://github.com/dicnunz)!) +- fix: correct start-time setting for accurate TTFT metric value (#3668) +- fix: map Vertex traffic type to Bifrost service tier (#3662) +- fix: ListModels for keyless providers (#3655) +- fix: remove manual `type: custom` for Anthropic tools (#3652) diff --git a/framework/changelog.md b/framework/changelog.md index e69de29bb2..c5ae6b3bb2 100644 --- a/framework/changelog.md +++ b/framework/changelog.md @@ -0,0 +1,5 @@ +- feat: `created_by` user attribution column for virtual keys (#3672) +- feat: `blacklisted_models` column for virtual key provider configs (#3653) +- fix: add monotonic `inc_number` log cursor so node usage reconciliation does not skip late async log writes (#3664) +- revert: `access_profile_id` direct access profile assignment on virtual keys (#3669) +- chore: drop the `access_profile_id` column from `governance_virtual_keys` (#3670) diff --git a/plugins/compat/changelog.md b/plugins/compat/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/compat/changelog.md +++ b/plugins/compat/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/plugins/governance/changelog.md b/plugins/governance/changelog.md index e69de29bb2..1b1dafa9b9 100644 --- a/plugins/governance/changelog.md +++ b/plugins/governance/changelog.md @@ -0,0 +1,2 @@ +- feat: virtual key blocked-models enforcement — reject requests when the requested model is blocked at the VK provider-config level (#3653) +- fix: clear stale `governanceRejectedContextKey` on an allow decision so successful fallback retries count toward budgets and rate limits (#3645) diff --git a/plugins/jsonparser/changelog.md b/plugins/jsonparser/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/jsonparser/changelog.md +++ b/plugins/jsonparser/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/plugins/logging/changelog.md b/plugins/logging/changelog.md index e69de29bb2..c38e9977c4 100644 --- a/plugins/logging/changelog.md +++ b/plugins/logging/changelog.md @@ -0,0 +1 @@ +- feat: stamp MCP tool logs with governance ownership (user, team, customer, and business unit IDs) from the request context diff --git a/plugins/maxim/changelog.md b/plugins/maxim/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/maxim/changelog.md +++ b/plugins/maxim/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/plugins/mocker/changelog.md b/plugins/mocker/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/mocker/changelog.md +++ b/plugins/mocker/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/plugins/otel/changelog.md b/plugins/otel/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/otel/changelog.md +++ b/plugins/otel/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/plugins/prompts/changelog.md b/plugins/prompts/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/prompts/changelog.md +++ b/plugins/prompts/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/plugins/semanticcache/changelog.md b/plugins/semanticcache/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/semanticcache/changelog.md +++ b/plugins/semanticcache/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/plugins/telemetry/changelog.md b/plugins/telemetry/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/telemetry/changelog.md +++ b/plugins/telemetry/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/transports/changelog.md b/transports/changelog.md index e69de29bb2..5aa94e82e9 100644 --- a/transports/changelog.md +++ b/transports/changelog.md @@ -0,0 +1,22 @@ +## ✨ Features + +- **Virtual Key Blocked Models** — Block specific models at the virtual key provider-config level; blocked models take priority over allowed models and are enforced by governance (#3653) +- **Virtual Key Ownership** — Virtual keys now capture and display a `created_by` user attribution (#3672) +- **MCP Log Attribution** — MCP tool logs are stamped with user, team, customer, and business unit IDs so MCP usage can be traced like LLM usage +- **Team & Business Unit Filters** — Added team and business unit filters across the dashboard and logs views (#3650) +- **Sticky Time Filters** — Time filter selections are preserved when navigating between sidebar items (#3647) + +## 🐞 Fixed + +- **Idle Timeout Panic** — Fixed a panic in the streaming idle-timeout reader and added a guard to skip reads once the connection is closed (#3672) +- **Anthropic Streaming** — Preserve the tool-call stop reason in the Anthropic streaming fallback (#3640) (thanks [@dicnunz](https://github.com/dicnunz)!) +- **TTFT Metric** — Fixed the request start-time setting so the time-to-first-token metric is accurate (#3668) +- **Vertex Service Tier** — Map the Vertex traffic type to the correct Bifrost service tier (#3662) +- **Keyless Providers** — Fixed `ListModels` for providers configured without an API key (#3655) +- **Anthropic Tools** — Stopped forcing `type: custom` on Anthropic tool definitions (#3652) +- **Node Usage Reconciliation** — Added a monotonic log cursor so reconciliation no longer skips late async log writes (#3664) +- **Fallback Budget Tracking** — Clear the stale governance rejection flag on allow so successful fallback retries count toward budgets and rate limits (#3645) +- **Virtual Keys Table** — Table now fills available height with a sticky header and scrollable body (#3676) +- **Sheet Layout** — Removed save/cancel icons and fixed sheet layout growth in routing rule and virtual key sheets (#3675) +- **Toast Click-Through** — Toasts remain clickable above modal overlays (#3674) +- **Direct Access Control** — Reverted the virtual key `access_profile_id` direct access profile assignment shipped in v1.5.3; the `access_profile_id` column has been dropped (#3669, #3670) From dc124e0f0851adde34717f3ab4eb67b332083cc1 Mon Sep 17 00:00:00 2001 From: Samyabrata Maji Date: Fri, 22 May 2026 17:42:49 +0530 Subject: [PATCH 3/5] fix: update e2e ui tests (#3687) ## Summary Improves E2E test stability for virtual key editing by handling a budget reset dialog that can appear after saving, and marks a known flaky bulk-rotate test as `fixme` until the underlying UI bug is resolved. ## Changes - Added `preserveBudgetUsageIfPrompted()` helper that detects the `vk-budget-reset-dialog` and clicks the preserve button if it appears after saving a virtual key. This prevents test failures caused by an unexpected dialog interrupting the save flow. - Marked `should bulk rotate selected virtual keys only` as `test.fixme` due to a UI bug where the checkbox selection state resets when the search input filters out a previously selected row. When `bulkRotateVirtualKeys` searches by name to select each key, the first key becomes deselected as the search narrows to the second, resulting in only the last key being rotated. ## Type of change - [ ] Bug fix - [ ] Feature - [ ] Refactor - [ ] Documentation - [x] Chore/CI ## Affected areas - [ ] Core (Go) - [ ] Transports (HTTP) - [ ] Providers/Integrations - [ ] Plugins - [ ] UI (React) - [ ] Docs ## How to test Run the virtual keys E2E suite and confirm no failures occur on the save flow due to the budget reset dialog: ```sh cd tests/e2e npx playwright test features/virtual-keys/virtual-keys.spec.ts ``` The bulk rotate test will be skipped (`fixme`) and should not cause CI failures. ## Screenshots/Recordings N/A ## Breaking changes - [ ] Yes - [x] No ## Related issues N/A ## Security considerations None. ## Checklist - [x] I read `docs/contributing/README.md` and followed the guidelines - [x] I added/updated tests where appropriate - [x] I updated documentation where needed - [x] I verified builds succeed (Go and UI) - [x] I verified the CI pipeline passes locally if applicable --- .../features/virtual-keys/pages/virtual-keys.page.ts | 11 ++++++++++- tests/e2e/features/virtual-keys/virtual-keys.spec.ts | 8 +++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/e2e/features/virtual-keys/pages/virtual-keys.page.ts b/tests/e2e/features/virtual-keys/pages/virtual-keys.page.ts index 08c06be7e0..593c915154 100644 --- a/tests/e2e/features/virtual-keys/pages/virtual-keys.page.ts +++ b/tests/e2e/features/virtual-keys/pages/virtual-keys.page.ts @@ -213,6 +213,14 @@ export class VirtualKeysPage extends BasePage { .catch(() => {}); } + private async preserveBudgetUsageIfPrompted(): Promise { + const dialog = this.page.getByTestId("vk-budget-reset-dialog"); + const isVisible = await dialog.waitFor({ state: 'visible', timeout: 1000 }).then(() => true).catch(() => false); + if (!isVisible) return; + await this.page.getByTestId("vk-budget-reset-preserve-btn").click(); + await dialog.waitFor({ state: 'hidden', timeout: 3000 }) + } + /** * Check if a virtual key exists in the table */ @@ -471,6 +479,7 @@ export class VirtualKeysPage extends BasePage { // Save changes by clicking the save button await this.saveBtn.click(); + await this.preserveBudgetUsageIfPrompted(); await this.waitForSheetClosedAfterSave(); @@ -865,4 +874,4 @@ export class VirtualKeysPage extends BasePage { } } } -} +} \ No newline at end of file diff --git a/tests/e2e/features/virtual-keys/virtual-keys.spec.ts b/tests/e2e/features/virtual-keys/virtual-keys.spec.ts index 77d5a3f6e1..a555926a94 100644 --- a/tests/e2e/features/virtual-keys/virtual-keys.spec.ts +++ b/tests/e2e/features/virtual-keys/virtual-keys.spec.ts @@ -458,7 +458,13 @@ test.describe('Virtual Key Management', () => { expect(currentValue).toBe(oldValue) }) - test('should bulk rotate selected virtual keys only', async ({ virtualKeysPage }) => { + // TODO: Re-enable once the UI bug is fixed. + // Bug: selection state resets when the search input filters out an already-selected + // row (i.e. selected keys not present in the current search results lose their + // checked state). Because `bulkRotateVirtualKeys` searches per-name to tick each + // checkbox, the first key gets unselected when the search narrows to the second, + // so only the last selection ends up being rotated. + test.fixme('should bulk rotate selected virtual keys only', async ({ virtualKeysPage }) => { const selectedOne = `Bulk Rotate One ${Date.now()}` const selectedTwo = `Bulk Rotate Two ${Date.now()}` const unselected = `Bulk Rotate Unselected ${Date.now()}` From ff463d9df7dbf20e6384a98f458d51f754f42947 Mon Sep 17 00:00:00 2001 From: Samyabrata Maji Date: Fri, 22 May 2026 19:02:07 +0530 Subject: [PATCH 4/5] fix: updates mcp oauth api tests (#3693) ## Summary Consolidates the Per User OAuth Postman coverage probes to align with the updated API surface, replacing the old register/authorize/token/consent/upstream endpoints with the new flow-based endpoints (`/api/oauth/per-user/flows/:flowId` and `/api/oauth/per-user/flows/:flowId/start`). ## Changes - Removed coverage probe requests for `Per User OAuth Register`, `Per User OAuth Authorize`, `Per User OAuth Token`, `Per User OAuth Upstream Authorize`, `Per User Consent VK`, `Per User Consent User ID`, `Per User Consent Skip`, and `Per User Consent Submit` - Added coverage probe requests for `Per User OAuth Flow Detail` (`GET /api/oauth/per-user/flows/coverage-probe-flow`) and `Per User OAuth Flow Start` (`GET /api/oauth/per-user/flows/coverage-probe-flow/start`) - Added `OAuth Callback (Coverage Probe)` to the raw text shapes validation map, replacing the three previously tracked OAuth probe entries ## Type of change - [ ] Bug fix - [ ] Feature - [ ] Refactor - [ ] Documentation - [x] Chore/CI ## Affected areas - [ ] Core (Go) - [x] Transports (HTTP) - [ ] Providers/Integrations - [ ] Plugins - [ ] UI (React) - [ ] Docs ## How to test Run the updated Postman collection against a running Bifrost instance and verify that the new flow-based coverage probe requests return expected responses and that the response structure validation passes. ## Breaking changes - [ ] Yes - [x] No ## Related issues ## Security considerations No security implications. These are test coverage probes only. ## Checklist - [x] I read `docs/contributing/README.md` and followed the guidelines - [x] I added/updated tests where appropriate - [x] I updated documentation where needed - [x] I verified builds succeed (Go and UI) - [x] I verified the CI pipeline passes locally if applicable --- ...ost-api-management.postman_collection.json | 202 ++---------------- 1 file changed, 15 insertions(+), 187 deletions(-) diff --git a/tests/e2e/api/collections/bifrost-api-management.postman_collection.json b/tests/e2e/api/collections/bifrost-api-management.postman_collection.json index d84e383100..e510b4835d 100644 --- a/tests/e2e/api/collections/bifrost-api-management.postman_collection.json +++ b/tests/e2e/api/collections/bifrost-api-management.postman_collection.json @@ -82,7 +82,7 @@ "}", "", "// Handle 405 for unimplemented cache endpoints", - "var unimplementedEndpoints = ['Clear Cache by Request ID', 'Clear Cache by Key'];", + "var unimplementedEndpoints = ['Clear Cache by Cache ID (Coverage Probe)', 'Clear Cache by Key (Coverage Probe)'];", "if (unimplementedEndpoints.indexOf(requestName) !== -1 && code === 405) {", " pass = true;", "}", @@ -233,11 +233,9 @@ " 'Get Version': true", "};", "var rawTextShapes = {", - " 'Clear Cache by Request ID': true,", - " 'Clear Cache by Key': true,", - " 'Per User OAuth Register (Coverage Probe)': true,", - " 'Per User OAuth Authorize (Coverage Probe)': true,", - " 'Per User OAuth Token (Coverage Probe)': true", + " 'Clear Cache by Cache ID (Coverage Probe)': true,", + " 'Clear Cache by Key (Coverage Probe)': true,", + " 'OAuth Callback (Coverage Probe)': true", "};", "pm.test('Response structure matches handler contract', function () {", " var body = parseJSONOrNull();", @@ -2156,7 +2154,7 @@ "name": "Cache", "item": [ { - "name": "Clear Cache by Request ID", + "name": "Clear Cache by Cache ID (Coverage Probe)", "request": { "method": "DELETE", "header": [], @@ -2175,7 +2173,7 @@ } }, { - "name": "Clear Cache by Key", + "name": "Clear Cache by Key (Coverage Probe)", "request": { "method": "DELETE", "header": [], @@ -2770,64 +2768,12 @@ } }, { - "name": "Per User OAuth Register (Coverage Probe)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": { - "raw": "{{base_url}}/api/oauth/per-user/register", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "oauth", - "per-user", - "register" - ] - }, - "body": { - "mode": "raw", - "raw": "{\"redirect_uris\":[\"http://localhost/callback\"],\"client_name\":\"coverage-probe\"}" - } - } - }, - { - "name": "Per User OAuth Authorize (Coverage Probe)", + "name": "Per User OAuth Flow Detail (Coverage Probe)", "request": { "method": "GET", "header": [], "url": { - "raw": "{{base_url}}/api/oauth/per-user/authorize?client_id=coverage-probe-client&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&response_type=code&state=coverage-probe-state", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "oauth", - "per-user", - "authorize?client_id=coverage-probe-client&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&response_type=code&state=coverage-probe-state" - ] - } - } - }, - { - "name": "Per User OAuth Token (Coverage Probe)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": { - "raw": "{{base_url}}/api/oauth/per-user/token", + "raw": "{{base_url}}/api/oauth/per-user/flows/coverage-probe-flow", "host": [ "{{base_url}}" ], @@ -2835,134 +2781,19 @@ "api", "oauth", "per-user", - "token" + "flows", + "coverage-probe-flow" ] - }, - "body": { - "mode": "raw", - "raw": "{\"grant_type\":\"authorization_code\",\"code\":\"coverage-probe-code\",\"redirect_uri\":\"http://localhost/callback\"}" } } }, { - "name": "Per User OAuth Upstream Authorize (Coverage Probe)", + "name": "Per User OAuth Flow Start (Coverage Probe)", "request": { "method": "GET", "header": [], "url": { - "raw": "{{base_url}}/api/oauth/per-user/upstream/authorize?state=coverage-probe-state", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "oauth", - "per-user", - "upstream", - "authorize?state=coverage-probe-state" - ] - } - } - }, - { - "name": "Per User Consent VK (Coverage Probe)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": { - "raw": "{{base_url}}/api/oauth/per-user/consent/vk", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "oauth", - "per-user", - "consent", - "vk" - ] - }, - "body": { - "mode": "raw", - "raw": "{\"state\":\"coverage-probe-state\",\"virtual_key\":\"coverage-probe-vk\"}" - } - } - }, - { - "name": "Per User Consent User ID (Coverage Probe)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": { - "raw": "{{base_url}}/api/oauth/per-user/consent/user-id", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "oauth", - "per-user", - "consent", - "user-id" - ] - }, - "body": { - "mode": "raw", - "raw": "{\"state\":\"coverage-probe-state\",\"user_id\":\"coverage-probe-user\"}" - } - } - }, - { - "name": "Per User Consent Skip (Coverage Probe)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": { - "raw": "{{base_url}}/api/oauth/per-user/consent/skip", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "oauth", - "per-user", - "consent", - "skip" - ] - }, - "body": { - "mode": "raw", - "raw": "{\"state\":\"coverage-probe-state\"}" - } - } - }, - { - "name": "Per User Consent Submit (Coverage Probe)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": { - "raw": "{{base_url}}/api/oauth/per-user/consent/submit", + "raw": "{{base_url}}/api/oauth/per-user/flows/coverage-probe-flow/start", "host": [ "{{base_url}}" ], @@ -2970,13 +2801,10 @@ "api", "oauth", "per-user", - "consent", - "submit" + "flows", + "coverage-probe-flow", + "start" ] - }, - "body": { - "mode": "raw", - "raw": "{\"state\":\"coverage-probe-state\"}" } } }, From 38393500e47d7481e623134318b0f67f09e92421 Mon Sep 17 00:00:00 2001 From: Anuj Parihar Date: Tue, 19 May 2026 03:26:10 +0530 Subject: [PATCH 5/5] feat: add support for env variables on certain fields in plugins --- framework/configstore/tables/plugin.go | 114 ++++++++++++ plugins/maxim/main.go | 21 ++- plugins/maxim/plugin_test.go | 18 +- plugins/otel/main.go | 27 +-- plugins/telemetry/main.go | 20 +- transports/bifrost-http/handlers/plugins.go | 16 +- .../fragments/maximFormFragment.tsx | 58 +++--- .../fragments/otelFormFragment.tsx | 32 ++-- .../fragments/prometheusFormFragment.tsx | 82 ++++---- .../views/plugins/prometheusView.tsx | 16 +- ui/lib/types/schemas.ts | 175 +++++++----------- 11 files changed, 337 insertions(+), 242 deletions(-) diff --git a/framework/configstore/tables/plugin.go b/framework/configstore/tables/plugin.go index 2b61b9b1b4..33baf1a6dc 100644 --- a/framework/configstore/tables/plugin.go +++ b/framework/configstore/tables/plugin.go @@ -3,6 +3,8 @@ package tables import ( "encoding/json" "fmt" + "os" + "strings" "time" "github.com/maximhq/bifrost/core/schemas" @@ -39,10 +41,91 @@ type TablePlugin struct { // TableName sets the table name for each model func (TablePlugin) TableName() string { return "config_plugins" } +// ErrUnresolvedEnvVars is returned when a plugin config references environment variables +// that are not set in the process environment at save time. +type ErrUnresolvedEnvVars struct { + Vars []string +} + +func (e *ErrUnresolvedEnvVars) Error() string { + return fmt.Sprintf("environment variables not set: %s", strings.Join(e.Vars, ", ")) +} + +// normalizePluginConfigForStorage recursively walks a plugin config map and converts +// any EnvVar-shaped object ({value, env_var, from_env}) into a plain string — +// either the literal value or the "env.VAR_NAME" token. This keeps stored JSON +// consistent with the plain-string format used by config.json and ProxyConfig.MarshalForStorage. +func normalizePluginConfigForStorage(v any) any { + switch val := v.(type) { + case map[string]any: + // Detect an EnvVar object: presence of any EnvVar key is sufficient — sparse + // objects (e.g. only from_env+env_var, without value) are valid. + _, hasValue := val["value"] + _, hasEnvVar := val["env_var"] + _, hasFromEnv := val["from_env"] + if hasValue || hasEnvVar || hasFromEnv { + if fromEnv, ok := val["from_env"].(bool); ok && fromEnv { + if envVar, ok := val["env_var"].(string); ok && envVar != "" { + return envVar // "env.VAR_NAME" + } + } + if value, ok := val["value"].(string); ok { + return value + } + } + // Regular nested map — recurse + result := make(map[string]any, len(val)) + for k, child := range val { + result[k] = normalizePluginConfigForStorage(child) + } + return result + case []any: + result := make([]any, len(val)) + for i, item := range val { + result[i] = normalizePluginConfigForStorage(item) + } + return result + default: + return v + } +} + +// checkEnvVarsResolved walks a normalized plugin config (after normalizePluginConfigForStorage) +// and returns any "env.VAR_NAME" references whose env var is not set in the process environment. +func checkEnvVarsResolved(v any) []string { + var unresolved []string + switch val := v.(type) { + case string: + if strings.HasPrefix(val, "env.") { + envKey := strings.TrimPrefix(val, "env.") + if _, ok := os.LookupEnv(envKey); !ok { + unresolved = append(unresolved, val) + } + } + case map[string]any: + for _, child := range val { + unresolved = append(unresolved, checkEnvVarsResolved(child)...) + } + case []any: + for _, item := range val { + unresolved = append(unresolved, checkEnvVarsResolved(item)...) + } + } + return unresolved +} + // BeforeSave is a GORM hook that serializes the plugin Config into a JSON column and // encrypts it before writing to the database. Empty configs ("{}") are not encrypted. func (p *TablePlugin) BeforeSave(tx *gorm.DB) error { if p.Config != nil { + // Normalize any EnvVar-shaped objects to plain strings before marshaling + // so the DB always stores "env.VAR_NAME" or literal values, not JSON objects. + if configMap, ok := p.Config.(map[string]any); ok { + p.Config = normalizePluginConfigForStorage(configMap) + } + if unresolved := checkEnvVarsResolved(p.Config); len(unresolved) > 0 { + return &ErrUnresolvedEnvVars{Vars: unresolved} + } data, err := json.Marshal(p.Config) if err != nil { return err @@ -65,6 +148,34 @@ func (p *TablePlugin) BeforeSave(tx *gorm.DB) error { return nil } +// denormalizePluginConfigFromStorage is the inverse of normalizePluginConfigForStorage. +// It converts plain "env.VAR_NAME" strings back into {value, env_var, from_env} objects +// so the API response carries the same shape as provider key EnvVar fields. +func denormalizePluginConfigFromStorage(v any) any { + switch val := v.(type) { + case string: + if strings.HasPrefix(val, "env.") { + redacted := schemas.NewEnvVar(val).Redacted() + return map[string]any{"value": redacted.Val, "env_var": redacted.EnvVar, "from_env": redacted.FromEnv} + } + return val + case map[string]any: + result := make(map[string]any, len(val)) + for k, child := range val { + result[k] = denormalizePluginConfigFromStorage(child) + } + return result + case []any: + result := make([]any, len(val)) + for i, item := range val { + result[i] = denormalizePluginConfigFromStorage(item) + } + return result + default: + return v + } +} + // AfterFind is a GORM hook that decrypts the plugin config JSON (if encrypted) and // deserializes it back into the runtime Config field after reading from the database. func (p *TablePlugin) AfterFind(tx *gorm.DB) error { @@ -79,6 +190,9 @@ func (p *TablePlugin) AfterFind(tx *gorm.DB) error { if err := json.Unmarshal([]byte(p.ConfigJSON), &p.Config); err != nil { return err } + if configMap, ok := p.Config.(map[string]any); ok { + p.Config = denormalizePluginConfigFromStorage(configMap) + } } else { p.Config = nil } diff --git a/plugins/maxim/main.go b/plugins/maxim/main.go index eb9ca44927..0c3955a57f 100644 --- a/plugins/maxim/main.go +++ b/plugins/maxim/main.go @@ -26,11 +26,11 @@ const ( ) // Config is the configuration for the maxim plugin. -// - APIKey: API key for Maxim SDK authentication -// - LogRepoID: Optional default ID for the Maxim logger instance +// - APIKey: API key for Maxim SDK authentication; supports env.VAR_NAME +// - LogRepoID: Optional default ID for the Maxim logger instance; supports env.VAR_NAME type Config struct { - LogRepoID string `json:"log_repo_id,omitempty"` // Optional - can be empty - APIKey string `json:"api_key"` + LogRepoID schemas.EnvVar `json:"log_repo_id,omitempty"` // Optional; supports env.VAR_NAME + APIKey schemas.EnvVar `json:"api_key"` // supports env.VAR_NAME } // Plugin implements the schemas.LLMPlugin interface for Maxim's logger. @@ -63,27 +63,28 @@ func Init(config *Config, logger schemas.Logger) (schemas.LLMPlugin, error) { return nil, fmt.Errorf("config is required") } // check if Maxim Logger variables are set - if config.APIKey == "" { + if config.APIKey.GetValue() == "" { return nil, fmt.Errorf("apiKey is not set") } - mx := maxim.Init(&maxim.MaximSDKConfig{ApiKey: config.APIKey}) + mx := maxim.Init(&maxim.MaximSDKConfig{ApiKey: config.APIKey.GetValue()}) + logRepoID := config.LogRepoID.GetValue() plugin := &Plugin{ mx: mx, - defaultLogRepoID: config.LogRepoID, + defaultLogRepoID: logRepoID, loggers: make(map[string]*logging.Logger), loggerMutex: &sync.RWMutex{}, logger: logger, } // Initialize default logger if LogRepoId is provided - if config.LogRepoID != "" { - logger, err := mx.GetLogger(&logging.LoggerConfig{Id: config.LogRepoID}) + if logRepoID != "" { + logger, err := mx.GetLogger(&logging.LoggerConfig{Id: logRepoID}) if err != nil { return nil, fmt.Errorf("failed to initialize default logger: %w", err) } - plugin.loggers[config.LogRepoID] = logger + plugin.loggers[logRepoID] = logger } return plugin, nil diff --git a/plugins/maxim/plugin_test.go b/plugins/maxim/plugin_test.go index a2490c05bf..ef057ae545 100644 --- a/plugins/maxim/plugin_test.go +++ b/plugins/maxim/plugin_test.go @@ -31,8 +31,8 @@ func getPlugin() (schemas.LLMPlugin, error) { logger := bifrost.NewDefaultLogger(schemas.LogLevelDebug) plugin, err := Init(&Config{ - APIKey: os.Getenv("MAXIM_API_KEY"), - LogRepoID: os.Getenv("MAXIM_LOG_REPO_ID"), + APIKey: *schemas.NewEnvVar("env.MAXIM_API_KEY"), + LogRepoID: *schemas.NewEnvVar("env.MAXIM_LOG_REPO_ID"), }, logger) if err != nil { return nil, err @@ -208,24 +208,24 @@ func TestPluginInitialization(t *testing.T) { { name: "Valid config with both fields", config: Config{ - APIKey: "test-api-key", - LogRepoID: "test-repo-id", + APIKey: *schemas.NewEnvVar("test-api-key"), + LogRepoID: *schemas.NewEnvVar("test-repo-id"), }, expectError: false, }, { name: "Valid config with only API key", config: Config{ - APIKey: "test-api-key", - LogRepoID: "", + APIKey: *schemas.NewEnvVar("test-api-key"), + LogRepoID: *schemas.NewEnvVar(""), }, expectError: false, }, { name: "Invalid config - missing API key", config: Config{ - APIKey: "", - LogRepoID: "test-repo-id", + APIKey: *schemas.NewEnvVar(""), + LogRepoID: *schemas.NewEnvVar("test-repo-id"), }, expectError: true, }, @@ -242,7 +242,7 @@ func TestPluginInitialization(t *testing.T) { } else { // For valid configs, we can't test actual initialization without real API key // Just test the validation logic - if tt.config.APIKey == "" { + if tt.config.APIKey.GetValue() == "" { t.Skip("Skipping valid config test - would need real Maxim API key") } } diff --git a/plugins/otel/main.go b/plugins/otel/main.go index 6ca4d6634a..abbf64a250 100644 --- a/plugins/otel/main.go +++ b/plugins/otel/main.go @@ -61,7 +61,7 @@ type PluginSpanFilter struct { type Config struct { ServiceName string `json:"service_name"` - CollectorURL string `json:"collector_url"` + CollectorURL schemas.EnvVar `json:"collector_url"` // supports env.VAR_NAME Headers map[string]string `json:"headers"` TraceType TraceType `json:"trace_type"` Protocol Protocol `json:"protocol"` @@ -69,8 +69,8 @@ type Config struct { Insecure bool `json:"insecure"` // Skip TLS when true; ignored if TLSCACert is set. Defaults to true when omitted. // Metrics push configuration - MetricsEnabled bool `json:"metrics_enabled"` - MetricsEndpoint string `json:"metrics_endpoint"` + MetricsEnabled bool `json:"metrics_enabled"` + MetricsEndpoint schemas.EnvVar `json:"metrics_endpoint"` // supports env.VAR_NAME MetricsPushInterval int `json:"metrics_push_interval"` // in seconds, default 15 // PluginSpanFilter is the DB-stored fallback when otel_plugin_span_filter is absent in config.json. @@ -133,6 +133,9 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa if config == nil { return nil, fmt.Errorf("config is required") } + if config.CollectorURL.GetValue() == "" { + return nil, fmt.Errorf("collector_url is required") + } logger = _logger if pricingManager == nil { logger.Warn("otel plugin requires model catalog to calculate cost, all cost calculations will be skipped.") @@ -187,10 +190,11 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa if nodeName := firstNonEmpty(os.Getenv("MY_NODE_NAME"), os.Getenv("NODE_NAME")); nodeName != "" { instanceAttrs = append(instanceAttrs, kvStr("k8s.node.name", nodeName)) } + collectorURL := config.CollectorURL.GetValue() // Preparing the plugin p := &OtelPlugin{ serviceName: config.ServiceName, - url: config.CollectorURL, + url: collectorURL, traceType: config.TraceType, headers: config.Headers, protocol: config.Protocol, @@ -198,17 +202,17 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa bifrostVersion: bifrostVersion, attributesFromEnvironment: attributesFromEnvironment, instanceAttrs: instanceAttrs, - pluginSpanFilter: config.PluginSpanFilter, + pluginSpanFilter: config.PluginSpanFilter, } p.ctx, p.cancel = context.WithCancel(ctx) if config.Protocol == ProtocolGRPC { - p.client, err = NewOtelClientGRPC(config.CollectorURL, config.Headers, config.TLSCACert, config.Insecure) + p.client, err = NewOtelClientGRPC(collectorURL, config.Headers, config.TLSCACert, config.Insecure) if err != nil { return nil, err } } if config.Protocol == ProtocolHTTP { - p.client, err = NewOtelClientHTTP(config.CollectorURL, config.Headers, config.TLSCACert, config.Insecure) + p.client, err = NewOtelClientHTTP(collectorURL, config.Headers, config.TLSCACert, config.Insecure) if err != nil { return nil, err } @@ -219,7 +223,8 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa // Initialize metrics exporter if enabled if config.MetricsEnabled { - if config.MetricsEndpoint == "" { + metricsEndpoint := config.MetricsEndpoint.GetValue() + if metricsEndpoint == "" { return nil, fmt.Errorf("metrics_endpoint is required when metrics_enabled is true") } pushInterval := config.MetricsPushInterval @@ -230,7 +235,7 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa } metricsConfig := &MetricsConfig{ ServiceName: config.ServiceName, - Endpoint: config.MetricsEndpoint, + Endpoint: metricsEndpoint, Headers: config.Headers, Protocol: config.Protocol, TLSCACert: config.TLSCACert, @@ -245,7 +250,7 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa } return nil, fmt.Errorf("failed to initialize metrics exporter: %w", err) } - logger.Info("OTEL metrics push enabled, pushing to %s every %d seconds", config.MetricsEndpoint, pushInterval) + logger.Info("OTEL metrics push enabled, pushing to %s every %d seconds", metricsEndpoint, pushInterval) } return p, nil @@ -295,7 +300,7 @@ func (p *OtelPlugin) ValidateConfig(config any) (*Config, error) { otelConfig = *config } // Validating fields - if otelConfig.CollectorURL == "" { + if otelConfig.CollectorURL.GetValue() == "" { return nil, fmt.Errorf("collector url is required") } if otelConfig.TraceType == "" { diff --git a/plugins/telemetry/main.go b/plugins/telemetry/main.go index 5ed599cf38..0aaf04931a 100644 --- a/plugins/telemetry/main.go +++ b/plugins/telemetry/main.go @@ -38,8 +38,8 @@ const ( type PushGatewayConfig struct { // Enabled controls whether pushing metrics to the Push Gateway is active Enabled bool `json:"enabled"` - // PushGatewayURL is the URL of the Prometheus Push Gateway (e.g., http://pushgateway:9091) - PushGatewayURL string `json:"push_gateway_url"` + // PushGatewayURL is the URL of the Prometheus Push Gateway (e.g., http://pushgateway:9091); supports env.VAR_NAME + PushGatewayURL schemas.EnvVar `json:"push_gateway_url"` // JobName is the job label for pushed metrics (default: "bifrost") JobName string `json:"job_name"` // InstanceID is the instance label for grouping metrics. If empty, hostname is used. @@ -52,8 +52,8 @@ type PushGatewayConfig struct { // BasicAuthConfig holds basic authentication credentials for the Push Gateway type BasicAuthConfig struct { - Username string `json:"username"` - Password string `json:"password"` + Username schemas.EnvVar `json:"username"` // supports env.VAR_NAME + Password schemas.EnvVar `json:"password"` // supports env.VAR_NAME } // PrometheusPlugin implements the schemas.LLMPlugin interface for Prometheus metrics. @@ -403,7 +403,7 @@ func Init(config *Config, pricingManager *modelcatalog.ModelCatalog, logger sche plugin.metricsEnabled.Store(metricsEnabled) // Start push gateway if configured - if config.PushGateway != nil && config.PushGateway.Enabled && config.PushGateway.PushGatewayURL != "" { + if config.PushGateway != nil && config.PushGateway.Enabled { if err := plugin.EnablePushGateway(config.PushGateway); err != nil { return nil, fmt.Errorf("failed to start push gateway: %w", err) } @@ -754,7 +754,7 @@ func (p *PrometheusPlugin) HTTPMiddleware(handler fasthttp.RequestHandler) fasth // EnablePushGateway starts pushing metrics to a Prometheus Push Gateway. // If push gateway is already active, it stops the existing one first. func (p *PrometheusPlugin) EnablePushGateway(config *PushGatewayConfig) error { - if config == nil || config.PushGatewayURL == "" { + if config == nil || config.PushGatewayURL.GetValue() == "" { return fmt.Errorf("push_gateway_url is required") } @@ -778,12 +778,12 @@ func (p *PrometheusPlugin) EnablePushGateway(config *PushGatewayConfig) error { } // Create the pusher with the registry - pusher := push.New(config.PushGatewayURL, config.JobName). + pusher := push.New(config.PushGatewayURL.GetValue(), config.JobName). Gatherer(p.registry). Grouping("instance", config.InstanceID) - if config.BasicAuth != nil && config.BasicAuth.Username != "" { - pusher = pusher.BasicAuth(config.BasicAuth.Username, config.BasicAuth.Password) + if config.BasicAuth != nil && config.BasicAuth.Username.GetValue() != "" && config.BasicAuth.Password.GetValue() != "" { + pusher = pusher.BasicAuth(config.BasicAuth.Username.GetValue(), config.BasicAuth.Password.GetValue()) } ctx, cancel := context.WithCancel(context.Background()) @@ -800,7 +800,7 @@ func (p *PrometheusPlugin) EnablePushGateway(config *PushGatewayConfig) error { go p.pushLoop() p.logger.Info("push gateway started, pushing to %s every %d seconds", - config.PushGatewayURL, config.PushInterval) + config.PushGatewayURL.GetValue(), config.PushInterval) return nil } diff --git a/transports/bifrost-http/handlers/plugins.go b/transports/bifrost-http/handlers/plugins.go index 2d94152ff9..ae58b8cb92 100644 --- a/transports/bifrost-http/handlers/plugins.go +++ b/transports/bifrost-http/handlers/plugins.go @@ -295,7 +295,12 @@ func (h *PluginsHandler) createPlugin(ctx *fasthttp.RequestCtx) { Order: request.Order, }); err != nil { logger.Error("failed to create plugin: %v", err) - SendError(ctx, 500, "Failed to create plugin") + var envErr *configstoreTables.ErrUnresolvedEnvVars + if errors.As(err, &envErr) { + SendError(ctx, fasthttp.StatusBadRequest, envErr.Error()) + return + } + SendError(ctx, fasthttp.StatusInternalServerError, "Failed to create plugin") return } @@ -426,7 +431,12 @@ func (h *PluginsHandler) updatePlugin(ctx *fasthttp.RequestCtx) { Order: request.Order, }); err != nil { logger.Error("failed to update plugin: %v", err) - SendError(ctx, 500, "Failed to update plugin") + var envErr *configstoreTables.ErrUnresolvedEnvVars + if errors.As(err, &envErr) { + SendError(ctx, fasthttp.StatusBadRequest, err.Error()) + return + } + SendError(ctx, fasthttp.StatusInternalServerError, "Failed to update plugin") return } plugin, err = h.configStore.GetPlugin(ctx, name) @@ -436,7 +446,7 @@ func (h *PluginsHandler) updatePlugin(ctx *fasthttp.RequestCtx) { return } logger.Error("failed to get plugin: %v", err) - SendError(ctx, 500, "Failed to retrieve plugin") + SendError(ctx, fasthttp.StatusInternalServerError, "Failed to retrieve plugin") return } // We reload the plugin if its enabled, otherwise we stop it diff --git a/ui/app/workspace/observability/fragments/maximFormFragment.tsx b/ui/app/workspace/observability/fragments/maximFormFragment.tsx index 123b42b94a..312ffd6f2f 100644 --- a/ui/app/workspace/observability/fragments/maximFormFragment.tsx +++ b/ui/app/workspace/observability/fragments/maximFormFragment.tsx @@ -1,20 +1,20 @@ import { Button } from "@/components/ui/button"; +import { EnvVarInput } from "@/components/ui/envVarInput"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { maximFormSchema, type MaximFormSchema } from "@/lib/types/schemas"; +import { maximFormSchema, normalizeEnvVar, type MaximFormSchema } from "@/lib/types/schemas"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Eye, EyeOff, Trash2 } from "lucide-react"; +import { Trash2 } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm, type Resolver } from "react-hook-form"; interface MaximFormFragmentProps { initialConfig?: { enabled?: boolean; - api_key?: string; - log_repo_id?: string; + api_key?: string | { value?: string; env_var?: string; from_env?: boolean }; + log_repo_id?: string | { value?: string; env_var?: string; from_env?: boolean }; }; onSave: (config: MaximFormSchema) => Promise; onDelete?: () => void; @@ -24,7 +24,6 @@ interface MaximFormFragmentProps { export function MaximFormFragment({ initialConfig, onSave, onDelete, isDeleting = false, isLoading = false }: MaximFormFragmentProps) { const hasMaximAccess = useRbac(RbacResource.Observability, RbacOperation.Update); - const [showApiKey, setShowApiKey] = useState(false); const [isSaving, setIsSaving] = useState(false); const form = useForm({ @@ -34,8 +33,8 @@ export function MaximFormFragment({ initialConfig, onSave, onDelete, isDeleting defaultValues: { enabled: initialConfig?.enabled ?? true, maxim_config: { - api_key: initialConfig?.api_key ?? "", - log_repo_id: initialConfig?.log_repo_id ?? "", + api_key: normalizeEnvVar(initialConfig?.api_key), + log_repo_id: normalizeEnvVar(initialConfig?.log_repo_id), }, }, }); @@ -50,8 +49,8 @@ export function MaximFormFragment({ initialConfig, onSave, onDelete, isDeleting form.reset({ enabled: initialConfig?.enabled ?? true, maxim_config: { - api_key: initialConfig?.api_key ?? "", - log_repo_id: initialConfig?.log_repo_id ?? "", + api_key: normalizeEnvVar(initialConfig?.api_key), + log_repo_id: normalizeEnvVar(initialConfig?.log_repo_id), }, }); }, [form, initialConfig]); @@ -68,25 +67,14 @@ export function MaximFormFragment({ initialConfig, onSave, onDelete, isDeleting API Key -
- - -
+
@@ -100,7 +88,13 @@ export function MaximFormFragment({ initialConfig, onSave, onDelete, isDeleting Log Repository ID (Optional) - + @@ -148,8 +142,8 @@ export function MaximFormFragment({ initialConfig, onSave, onDelete, isDeleting form.reset({ enabled: initialConfig?.enabled ?? true, maxim_config: { - api_key: initialConfig?.api_key ?? "", - log_repo_id: initialConfig?.log_repo_id ?? "", + api_key: normalizeEnvVar(initialConfig?.api_key), + log_repo_id: normalizeEnvVar(initialConfig?.log_repo_id), }, }); }} diff --git a/ui/app/workspace/observability/fragments/otelFormFragment.tsx b/ui/app/workspace/observability/fragments/otelFormFragment.tsx index 85e0d1af62..0157e9e541 100644 --- a/ui/app/workspace/observability/fragments/otelFormFragment.tsx +++ b/ui/app/workspace/observability/fragments/otelFormFragment.tsx @@ -1,12 +1,13 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { EnvVarInput } from "@/components/ui/envVarInput"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { HeadersTable } from "@/components/ui/headersTable"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { otelFormSchema, type OtelFormSchema } from "@/lib/types/schemas"; +import { normalizeEnvVar, otelFormSchema, type OtelFormSchema } from "@/lib/types/schemas"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; import { zodResolver } from "@hookform/resolvers/zod"; import { Trash2 } from "lucide-react"; @@ -17,7 +18,7 @@ interface OtelFormFragmentProps { currentConfig?: { enabled?: boolean; service_name?: string; - collector_url?: string; + collector_url?: string | { value?: string; env_var?: string; from_env?: boolean }; headers?: Record; trace_type?: "genai_extension" | "vercel" | "open_inference"; protocol?: "http" | "grpc"; @@ -26,7 +27,7 @@ interface OtelFormFragmentProps { insecure?: boolean; // Metrics push configuration metrics_enabled?: boolean; - metrics_endpoint?: string; + metrics_endpoint?: string | { value?: string; env_var?: string; from_env?: boolean }; metrics_push_interval?: number; }; onSave: (config: OtelFormSchema) => Promise; @@ -52,14 +53,14 @@ export function OtelFormFragment({ enabled: initialConfig?.enabled ?? true, otel_config: { service_name: initialConfig?.service_name ?? "bifrost", - collector_url: initialConfig?.collector_url ?? "", + collector_url: normalizeEnvVar(initialConfig?.collector_url), headers: initialConfig?.headers ?? {}, trace_type: initialConfig?.trace_type ?? "genai_extension", protocol: initialConfig?.protocol ?? "http", tls_ca_cert: initialConfig?.tls_ca_cert ?? "", insecure: initialConfig?.insecure ?? true, metrics_enabled: initialConfig?.metrics_enabled ?? false, - metrics_endpoint: initialConfig?.metrics_endpoint ?? "", + metrics_endpoint: normalizeEnvVar(initialConfig?.metrics_endpoint), metrics_push_interval: initialConfig?.metrics_push_interval ?? 15, }, }, @@ -96,14 +97,14 @@ export function OtelFormFragment({ enabled: initialConfig?.enabled ?? true, otel_config: { service_name: initialConfig?.service_name ?? "bifrost", - collector_url: initialConfig?.collector_url || "", + collector_url: normalizeEnvVar(initialConfig?.collector_url), headers: initialConfig?.headers || {}, trace_type: initialConfig?.trace_type || "genai_extension", protocol: initialConfig?.protocol || "http", tls_ca_cert: initialConfig?.tls_ca_cert ?? "", insecure: initialConfig?.insecure ?? true, metrics_enabled: initialConfig?.metrics_enabled ?? false, - metrics_endpoint: initialConfig?.metrics_endpoint ?? "", + metrics_endpoint: normalizeEnvVar(initialConfig?.metrics_endpoint), metrics_push_interval: initialConfig?.metrics_push_interval ?? 15, }, }); @@ -149,14 +150,16 @@ export function OtelFormFragment({ {form.watch("otel_config.protocol") === "http" ? "http(s)://:/v1/traces" : ":"}
- @@ -171,6 +174,7 @@ export function OtelFormFragment({ + Header values support env.VAR_NAME to source from environment variables. )} @@ -330,12 +334,14 @@ export function OtelFormFragment({ {form.watch("otel_config.protocol") === "http" ? "http(s)://:/v1/metrics" : ":"}
- @@ -410,14 +416,14 @@ export function OtelFormFragment({ enabled: initialConfig?.enabled ?? true, otel_config: { service_name: initialConfig?.service_name ?? "bifrost", - collector_url: initialConfig?.collector_url ?? "", + collector_url: normalizeEnvVar(initialConfig?.collector_url), headers: initialConfig?.headers ?? {}, trace_type: initialConfig?.trace_type ?? "genai_extension", protocol: initialConfig?.protocol ?? "http", tls_ca_cert: initialConfig?.tls_ca_cert ?? "", insecure: initialConfig?.insecure ?? true, metrics_enabled: initialConfig?.metrics_enabled ?? false, - metrics_endpoint: initialConfig?.metrics_endpoint ?? "", + metrics_endpoint: normalizeEnvVar(initialConfig?.metrics_endpoint), metrics_push_interval: initialConfig?.metrics_push_interval ?? 15, }, }); diff --git a/ui/app/workspace/observability/fragments/prometheusFormFragment.tsx b/ui/app/workspace/observability/fragments/prometheusFormFragment.tsx index 5e513ac4f3..abf1d97758 100644 --- a/ui/app/workspace/observability/fragments/prometheusFormFragment.tsx +++ b/ui/app/workspace/observability/fragments/prometheusFormFragment.tsx @@ -1,16 +1,17 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { EnvVarInput } from "@/components/ui/envVarInput"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; -import { prometheusFormSchema, type PrometheusFormSchema } from "@/lib/types/schemas"; +import { isEnvVarSet, normalizeEnvVar, prometheusFormSchema, type PrometheusFormSchema } from "@/lib/types/schemas"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; import { zodResolver } from "@hookform/resolvers/zod"; -import { AlertTriangle, Copy, Eye, EyeOff, Info, Plus, Trash, Trash2 } from "lucide-react"; +import { AlertTriangle, Copy, Info, Plus, Trash, Trash2 } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm, type Resolver } from "react-hook-form"; @@ -18,13 +19,13 @@ interface PrometheusFormFragmentProps { currentConfig?: { metrics_enabled?: boolean; push_gateway_enabled?: boolean; - push_gateway_url?: string; + push_gateway_url?: string | { value?: string; env_var?: string; from_env?: boolean }; job_name?: string; instance_id?: string; push_interval?: number; basic_auth?: { - username?: string; - password?: string; + username?: string | { value?: string; env_var?: string; from_env?: boolean }; + password?: string | { value?: string; env_var?: string; from_env?: boolean }; }; }; onSave: (config: PrometheusFormSchema) => Promise; @@ -38,12 +39,12 @@ const buildDefaults = (initialConfig?: PrometheusFormFragmentProps["currentConfi metrics_enabled: initialConfig?.metrics_enabled ?? true, push_gateway_enabled: initialConfig?.push_gateway_enabled ?? false, prometheus_config: { - push_gateway_url: initialConfig?.push_gateway_url ?? "", + push_gateway_url: normalizeEnvVar(initialConfig?.push_gateway_url), job_name: initialConfig?.job_name ?? "bifrost", instance_id: initialConfig?.instance_id ?? "", push_interval: initialConfig?.push_interval ?? 15, - basic_auth_username: initialConfig?.basic_auth?.username ?? "", - basic_auth_password: initialConfig?.basic_auth?.password ?? "", + basic_auth_username: normalizeEnvVar(initialConfig?.basic_auth?.username), + basic_auth_password: normalizeEnvVar(initialConfig?.basic_auth?.password), }, }); @@ -69,10 +70,11 @@ export function PrometheusFormFragment({ metricsEndpoint, }: PrometheusFormFragmentProps) { const hasPrometheusAccess = useRbac(RbacResource.Observability, RbacOperation.Update); - const [showPassword, setShowPassword] = useState(false); const [isSaving, setIsSaving] = useState(false); const { copy, copied } = useCopyToClipboard(); - const [showBasicAuth, setShowBasicAuth] = useState(!!(initialConfig?.basic_auth?.username || initialConfig?.basic_auth?.password)); + const [showBasicAuth, setShowBasicAuth] = useState( + isEnvVarSet(normalizeEnvVar(initialConfig?.basic_auth?.username)) || isEnvVarSet(normalizeEnvVar(initialConfig?.basic_auth?.password)), + ); const [activeTab, setActiveTab] = useState<"pull" | "push">("pull"); const form = useForm({ @@ -93,7 +95,9 @@ export function PrometheusFormFragment({ useEffect(() => { form.reset(buildDefaults(initialConfig)); - setShowBasicAuth(!!(initialConfig?.basic_auth?.username || initialConfig?.basic_auth?.password)); + setShowBasicAuth( + isEnvVarSet(normalizeEnvVar(initialConfig?.basic_auth?.username)) || isEnvVarSet(normalizeEnvVar(initialConfig?.basic_auth?.password)), + ); }, [form, initialConfig]); const handleCopyEndpoint = () => { @@ -103,11 +107,11 @@ export function PrometheusFormFragment({ }; const handleRemoveBasicAuth = () => { - form.setValue("prometheus_config.basic_auth_username", "", { + form.setValue("prometheus_config.basic_auth_username", normalizeEnvVar(""), { shouldDirty: true, shouldValidate: true, }); - form.setValue("prometheus_config.basic_auth_password", "", { + form.setValue("prometheus_config.basic_auth_password", normalizeEnvVar(""), { shouldDirty: true, shouldValidate: true, }); @@ -140,15 +144,17 @@ export function PrometheusFormFragment({ shouldValidate: true, }); form.setValue("prometheus_config.push_interval", defaults.prometheus_config.push_interval, { shouldDirty: true, shouldValidate: true }); - form.setValue("prometheus_config.basic_auth_username", defaults.prometheus_config.basic_auth_username ?? "", { + form.setValue("prometheus_config.basic_auth_username", defaults.prometheus_config.basic_auth_username ?? normalizeEnvVar(""), { shouldDirty: true, shouldValidate: true, }); - form.setValue("prometheus_config.basic_auth_password", defaults.prometheus_config.basic_auth_password ?? "", { + form.setValue("prometheus_config.basic_auth_password", defaults.prometheus_config.basic_auth_password ?? normalizeEnvVar(""), { shouldDirty: true, shouldValidate: true, }); - setShowBasicAuth(!!(initialConfig?.basic_auth?.username || initialConfig?.basic_auth?.password)); + setShowBasicAuth( + isEnvVarSet(normalizeEnvVar(initialConfig?.basic_auth?.username)) || isEnvVarSet(normalizeEnvVar(initialConfig?.basic_auth?.password)), + ); }; // Tabs can independently report whether *their* fields differ from the @@ -347,11 +353,12 @@ export function PrometheusFormFragment({ Push Gateway URL - URL of your Prometheus Push Gateway @@ -472,11 +479,12 @@ export function PrometheusFormFragment({ Username - @@ -491,28 +499,14 @@ export function PrometheusFormFragment({ Password -
- - -
+
diff --git a/ui/app/workspace/observability/views/plugins/prometheusView.tsx b/ui/app/workspace/observability/views/plugins/prometheusView.tsx index 0af0e275b7..9e6a8a2be2 100644 --- a/ui/app/workspace/observability/views/plugins/prometheusView.tsx +++ b/ui/app/workspace/observability/views/plugins/prometheusView.tsx @@ -1,18 +1,24 @@ import { getErrorMessage, useAppSelector, useUpdatePluginMutation } from "@/lib/store"; -import { PrometheusFormSchema } from "@/lib/types/schemas"; +import { isEnvVarSet, PrometheusFormSchema } from "@/lib/types/schemas"; import { useMemo } from "react"; import { toast } from "sonner"; import { PrometheusFormFragment } from "../../fragments/prometheusFormFragment"; +interface EnvVar { + value?: string; + env_var?: string; + from_env?: boolean; +} + interface PushGatewayConfig { enabled?: boolean; - push_gateway_url?: string; + push_gateway_url?: string | EnvVar; job_name?: string; instance_id?: string; push_interval?: number; basic_auth?: { - username?: string; - password?: string; + username?: string | EnvVar; + password?: string | EnvVar; }; } @@ -53,7 +59,7 @@ export default function PrometheusView({ onDelete, isDeleting }: PrometheusViewP push_interval: config.prometheus_config.push_interval, }; - if (config.prometheus_config.basic_auth_username?.trim() && config.prometheus_config.basic_auth_password?.trim()) { + if (isEnvVarSet(config.prometheus_config.basic_auth_username) && isEnvVarSet(config.prometheus_config.basic_auth_password)) { pushGatewayConfig.basic_auth = { username: config.prometheus_config.basic_auth_username, password: config.prometheus_config.basic_auth_password, diff --git a/ui/lib/types/schemas.ts b/ui/lib/types/schemas.ts index 36b785e935..b9df8fad1b 100644 --- a/ui/lib/types/schemas.ts +++ b/ui/lib/types/schemas.ts @@ -69,11 +69,21 @@ export const envVarSchema = Object.assign(_envVarBase, { }); // Helper to check if an envVar field has a value or env reference -function isEnvVarSet(v: { value?: string; env_var?: string } | undefined): boolean { +export function isEnvVarSet(v: { value?: string; env_var?: string } | undefined): boolean { if (!v) return false; return !!v.value?.trim() || !!v.env_var?.trim(); } +// Normalize a string | EnvVar | undefined to a proper EnvVar object +export function normalizeEnvVar(v?: string | { value?: string; env_var?: string; from_env?: boolean }): { value: string; env_var: string; from_env: boolean } { + if (!v) return { value: "", env_var: "", from_env: false }; + if (typeof v === "string") { + if (v.startsWith("env.")) return { value: "", env_var: v.slice(4), from_env: true }; + return { value: v, env_var: "", from_env: false }; + } + return { value: v.value ?? "", env_var: v.env_var ?? "", from_env: v.from_env ?? false }; +} + // Azure key config schema export const azureKeyConfigSchema = z .object({ @@ -740,7 +750,7 @@ export type BetaHeadersFormSchema = z.infer; export const otelConfigSchema = z .object({ service_name: z.string().optional(), - collector_url: z.string().default(""), + collector_url: envVarSchema.optional(), trace_type: z .enum(["genai_extension", "vercel", "open_inference"], { message: "Please select a trace type", @@ -757,7 +767,7 @@ export const otelConfigSchema = z insecure: z.boolean().default(true), // Metrics push configuration metrics_enabled: z.boolean().default(false), - metrics_endpoint: z.string().optional(), + metrics_endpoint: envVarSchema.optional(), metrics_push_interval: z.number().int().min(1).max(300).default(15), }) .superRefine((data, ctx) => { @@ -769,20 +779,12 @@ export const otelConfigSchema = z try { const u = new URL(url); if (!(u.protocol === "http:" || u.protocol === "https:")) { - ctx.addIssue({ - code: "custom", - path, - message: "Must be a valid HTTP or HTTPS URL", - }); + ctx.addIssue({ code: "custom", path, message: "Must be a valid HTTP or HTTPS URL" }); return false; } return true; } catch { - ctx.addIssue({ - code: "custom", - path, - message: "Must be a valid HTTP or HTTPS URL", - }); + ctx.addIssue({ code: "custom", path, message: "Must be a valid HTTP or HTTPS URL" }); return false; } }; @@ -791,46 +793,38 @@ export const otelConfigSchema = z const validateHostPort = (value: string, path: string[], example: string) => { const match = value.match(hostPortRegex); if (!match) { - ctx.addIssue({ - code: "custom", - path, - message: `Must be in the format : for gRPC (e.g. ${example})`, - }); + ctx.addIssue({ code: "custom", path, message: `Must be in the format : for gRPC (e.g. ${example})` }); return false; } const port = Number(match[2]); if (!(port >= 1 && port <= 65535)) { - ctx.addIssue({ - code: "custom", - path, - message: "Port must be between 1 and 65535", - }); + ctx.addIssue({ code: "custom", path, message: "Port must be between 1 and 65535" }); return false; } return true; }; - // Validate collector_url format (emptiness check is at form level, gated by enabled) - const collectorUrl = (data.collector_url || "").trim(); - if (collectorUrl && protocol === "http") { - validateHttpUrl(collectorUrl, ["collector_url"]); - } else if (collectorUrl && protocol === "grpc") { - validateHostPort(collectorUrl, ["collector_url"], "otel-collector:4317"); + // Validate collector_url format — skip format check when sourced from env var + if (data.collector_url && !data.collector_url.from_env) { + const collectorUrl = (data.collector_url.value || "").trim(); + if (collectorUrl && protocol === "http") { + validateHttpUrl(collectorUrl, ["collector_url"]); + } else if (collectorUrl && protocol === "grpc") { + validateHostPort(collectorUrl, ["collector_url"], "otel-collector:4317"); + } } // Validate metrics_endpoint when metrics_enabled is true if (data.metrics_enabled) { - const metricsEndpoint = (data.metrics_endpoint || "").trim(); - if (!metricsEndpoint) { - ctx.addIssue({ - code: "custom", - path: ["metrics_endpoint"], - message: "Metrics endpoint is required when metrics push is enabled", - }); - } else if (protocol === "http") { - validateHttpUrl(metricsEndpoint, ["metrics_endpoint"]); - } else if (protocol === "grpc") { - validateHostPort(metricsEndpoint, ["metrics_endpoint"], "otel-collector:4317"); + if (!isEnvVarSet(data.metrics_endpoint)) { + ctx.addIssue({ code: "custom", path: ["metrics_endpoint"], message: "Metrics endpoint is required when metrics push is enabled" }); + } else if (data.metrics_endpoint && !data.metrics_endpoint.from_env) { + const metricsEndpoint = (data.metrics_endpoint.value || "").trim(); + if (metricsEndpoint && protocol === "http") { + validateHttpUrl(metricsEndpoint, ["metrics_endpoint"]); + } else if (metricsEndpoint && protocol === "grpc") { + validateHostPort(metricsEndpoint, ["metrics_endpoint"], "otel-collector:4317"); + } } } }); @@ -842,22 +836,19 @@ export const otelFormSchema = z otel_config: otelConfigSchema, }) .superRefine((data, ctx) => { - if (data.enabled) { - const collectorUrl = (data.otel_config.collector_url || "").trim(); - if (!collectorUrl) { - ctx.addIssue({ - code: "custom", - path: ["otel_config", "collector_url"], - message: "Collector address is required", - }); - } + if (data.enabled && !isEnvVarSet(data.otel_config.collector_url)) { + ctx.addIssue({ + code: "custom", + path: ["otel_config", "collector_url"], + message: "Collector address is required", + }); } }); // Maxim Configuration Schema export const maximConfigSchema = z.object({ - api_key: z.string().default(""), - log_repo_id: z.string().optional(), + api_key: envVarSchema.optional(), + log_repo_id: envVarSchema.optional(), }); // Maxim form schema for the MaximFormFragment @@ -868,19 +859,10 @@ export const maximFormSchema = z }) .superRefine((data, ctx) => { if (data.enabled) { - const apiKey = (data.maxim_config.api_key || "").trim(); - if (!apiKey) { - ctx.addIssue({ - code: "custom", - path: ["maxim_config", "api_key"], - message: "API key is required", - }); - } else if (!apiKey.startsWith("sk_mx_")) { - ctx.addIssue({ - code: "custom", - path: ["maxim_config", "api_key"], - message: "API key must start with 'sk_mx_'", - }); + if (!isEnvVarSet(data.maxim_config.api_key)) { + ctx.addIssue({ code: "custom", path: ["maxim_config", "api_key"], message: "API key is required" }); + } else if (!data.maxim_config.api_key?.from_env && !data.maxim_config.api_key?.value?.startsWith("sk_mx_")) { + ctx.addIssue({ code: "custom", path: ["maxim_config", "api_key"], message: "API key must start with 'sk_mx_'" }); } } }); @@ -888,51 +870,37 @@ export const maximFormSchema = z // Prometheus Push Gateway Configuration Schema export const prometheusConfigSchema = z .object({ - push_gateway_url: z.string().optional(), + push_gateway_url: envVarSchema.optional(), job_name: z.string().default("bifrost"), instance_id: z.string().optional(), push_interval: z.number().min(1).max(300).default(15), - basic_auth_username: z.string().optional(), - basic_auth_password: z.string().optional(), + basic_auth_username: envVarSchema.optional(), + basic_auth_password: envVarSchema.optional(), }) .superRefine((data, ctx) => { - // Validate push_gateway_url format - const url = (data.push_gateway_url || "").trim(); - if (url) { - try { - const u = new URL(url); - if (!(u.protocol === "http:" || u.protocol === "https:")) { - ctx.addIssue({ - code: "custom", - path: ["push_gateway_url"], - message: "Must be a valid HTTP or HTTPS URL", - }); + // Validate push_gateway_url format — skip when sourced from env var + if (data.push_gateway_url && !data.push_gateway_url.from_env) { + const url = (data.push_gateway_url.value || "").trim(); + if (url) { + try { + const u = new URL(url); + if (!(u.protocol === "http:" || u.protocol === "https:")) { + ctx.addIssue({ code: "custom", path: ["push_gateway_url"], message: "Must be a valid HTTP or HTTPS URL" }); + } + } catch { + ctx.addIssue({ code: "custom", path: ["push_gateway_url"], message: "Must be a valid URL (e.g., http://pushgateway:9091)" }); } - } catch { - ctx.addIssue({ - code: "custom", - path: ["push_gateway_url"], - message: "Must be a valid URL (e.g., http://pushgateway:9091)", - }); } } // Validate basic auth: if one credential is provided, both must be provided - const hasUsername = !!data.basic_auth_username?.trim(); - const hasPassword = !!data.basic_auth_password?.trim(); + const hasUsername = isEnvVarSet(data.basic_auth_username); + const hasPassword = isEnvVarSet(data.basic_auth_password); if (hasUsername && !hasPassword) { - ctx.addIssue({ - code: "custom", - path: ["basic_auth_password"], - message: "Password is required when username is provided", - }); + ctx.addIssue({ code: "custom", path: ["basic_auth_password"], message: "Password is required when username is provided" }); } if (hasPassword && !hasUsername) { - ctx.addIssue({ - code: "custom", - path: ["basic_auth_username"], - message: "Username is required when password is provided", - }); + ctx.addIssue({ code: "custom", path: ["basic_auth_username"], message: "Username is required when password is provided" }); } }); @@ -944,15 +912,12 @@ export const prometheusFormSchema = z prometheus_config: prometheusConfigSchema, }) .superRefine((data, ctx) => { - if (data.push_gateway_enabled) { - const url = (data.prometheus_config.push_gateway_url || "").trim(); - if (!url) { - ctx.addIssue({ - code: "custom", - path: ["prometheus_config", "push_gateway_url"], - message: "Push Gateway URL is required when the push gateway is enabled", - }); - } + if (data.push_gateway_enabled && !isEnvVarSet(data.prometheus_config.push_gateway_url)) { + ctx.addIssue({ + code: "custom", + path: ["prometheus_config", "push_gateway_url"], + message: "Push Gateway URL is required when the push gateway is enabled", + }); } });