first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,361 @@
---
name: changelog-writer
description: Write changelogs for Bifrost releases. Reads git history, bumps module versions following the core→framework→plugins→transport hierarchy, writes transports/changelog.md (enterprise-style) and per-module changelog.md files, and updates version files. Invoked with /changelog-writer or /changelog-writer <transport-version>.
allowed-tools: Read, Grep, Glob, Bash, Edit, Write, Task, AskUserQuestion
---
# Changelog Writer
Generate changelogs for a new Bifrost release. Reads git history to identify changes per module, asks the user for version bump type, bumps all module versions respecting the dependency hierarchy, writes `transports/changelog.md` and per-module `changelog.md` files, and updates version files.
**IMPORTANT: This skill NEVER creates or modifies files under `docs/`.** No MDX files, no docs.json updates. Only `changelog.md` and `version` files within module directories.
## Module Hierarchy
Changes cascade down this dependency chain:
```
core → framework → plugins → transports
```
- **core** depends on nothing internal
- **framework** depends on core
- **plugins/*** each depend on core + framework
- **transports** depends on core + framework + all plugins
If core changes, every module below it must bump its version (at minimum a patch bump).
If framework changes (but not core), plugins and transports must bump.
If only a plugin changes, transports must bump.
If only transports changes, only transports bumps.
## Usage
```
/changelog-writer # Interactive — prompts for everything
/changelog-writer <transport-ver> # Pre-set transport version (e.g., v1.5.0)
```
## Workflow
### Step 1: Gather Current State
Read the current version of every module:
```bash
echo "core: $(cat core/version)"
echo "framework: $(cat framework/version)"
echo "transports: $(cat transports/version)"
for d in plugins/*/; do echo "$(basename $d): $(cat ${d}version)"; done
```
Read the latest changelog file to understand the previous release state:
```bash
# Find the latest docs changelog to determine the last released version
ls -1t docs/changelogs/*.mdx | head -1
```
Then read that file to know the previous versions of all modules.
### Step 2: Identify Changes Since Last Release
Use git log to find commits since the last release tag or since the last changelog was written:
```bash
# Get the transport version from the latest changelog (it matches the release version)
LAST_VERSION=$(ls -1t docs/changelogs/*.mdx | head -1 | sed 's/.*\/v/v/' | sed 's/.mdx//')
echo "Last release: $LAST_VERSION"
# Check if a git tag exists
git tag -l "$LAST_VERSION" "v*"
# Get commits since last release
# If tag exists:
git log ${LAST_VERSION}..HEAD --oneline --no-merges
# If no tag, use date-based or commit-based approach:
# Find the commit that added the last changelog
git log --oneline --all -- "docs/changelogs/$(ls -1t docs/changelogs/*.mdx | head -1 | xargs basename)" | head -1
```
For each module, identify which files changed:
```bash
# Changes in core
git diff --name-only ${BASE}..HEAD -- core/
# Changes in framework
git diff --name-only ${BASE}..HEAD -- framework/
# Changes in each plugin
for d in plugins/*/; do
CHANGES=$(git diff --name-only ${BASE}..HEAD -- "$d" | wc -l)
if [ "$CHANGES" -gt 0 ]; then
echo "$(basename $d): $CHANGES files changed"
fi
done
# Changes in transports
git diff --name-only ${BASE}..HEAD -- transports/
```
### Step 3: Classify Changes and Determine Bump Types
Present the identified changes to the user and ask what type of version bump each changed module needs.
**Always ask the user with AskUserQuestion what bump type to use for each module.**
Ask for **every** module that will be bumped — both modules with code changes and modules with only cascade bumps. Use AskUserQuestion with up to 4 questions at a time (the tool's limit), batching in hierarchy order:
1. First batch: core, framework, and up to 2 plugins
2. Continue with remaining plugins and transports
For each module ask: "What type of version bump for **{module}**?"
Options:
- **patch** — Bug fixes, small improvements (0.0.X)
- **minor** — New features, non-breaking changes (0.X.0)
- **major** — Breaking changes (X.0.0)
**Note:** Minor bumps reset the patch version to 0 (e.g., `1.4.24``1.5.0`). Patch bumps only increment the last number (e.g., `1.4.24``1.4.25`).
### Step 4: Calculate New Versions
Apply version bumps. Semver rules:
- **patch**: `1.4.4``1.4.5`
- **minor**: `1.4.4``1.5.0`
- **major**: `1.4.4``2.0.0`
Calculate new versions for ALL modules following the cascade rules:
```
new_core_version = bump(current_core, user_chosen_bump) if core changed, else current_core
new_framework_version = bump(current_framework, user_chosen_bump) if framework changed, else patch_bump(current_framework) if core changed, else current_framework
new_plugin_X_version = bump(current_plugin_X, user_chosen_bump) if plugin_X changed, else patch_bump(current_plugin_X) if core or framework changed, else current_plugin_X
new_transport_version = bump(current_transport, user_chosen_bump) if transport changed, else patch_bump(current_transport) if any upstream changed
```
**Present the version plan to the user for confirmation before proceeding.**
Show a table like:
```
Module Current New Bump Type Reason
core 1.4.4 1.5.0 minor code changes
framework 1.2.23 1.3.0 minor cascade from core
governance 1.4.24 1.4.25 patch cascade from core+framework
...
transports 1.4.9 1.5.0 minor cascade from all
```
Wait for user confirmation. If they want to adjust any version, update accordingly.
### Step 5: Collect and Write Changelog Entries
For each module, compose changelog entries from the git log.
**Read the actual git commits and changed code** to write meaningful entries:
```bash
# For each changed module, get detailed commit messages
git log ${BASE}..HEAD --oneline --no-merges -- core/
git log ${BASE}..HEAD --oneline --no-merges -- framework/
# etc.
```
#### Credit Outside Contributors
For each commit that references a PR number (e.g., `#1234`), check if the author is an outside contributor:
```bash
# Get the repo name
REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner')
# For each PR number found in commits:
gh api "repos/$REPO/pulls/<PR_NUMBER>" --jq '"\(.number) \(.user.login) \(.author_association)"'
```
**`author_association` values:**
- `MEMBER`, `OWNER`, `COLLABORATOR` → internal team, no credit needed
- `CONTRIBUTOR`, `FIRST_TIMER`, `FIRST_TIME_CONTRIBUTOR`, `NONE` → outside contributor, credit them
**How to credit:**
Use a markdown link to the contributor's GitHub profile: `[@username](https://github.com/username)`
- In **transports/changelog.md** (enterprise-style): append `(thanks [@username](https://github.com/username)!)` to the description
- Example: `- **Logprobs JSON Tag** — Fixed logprobs JSON tag in BifrostResponseChoice (thanks [@contributor](https://github.com/contributor)!)`
- In **per-module changelog.md** (flat-list): append `(thanks [@username](https://github.com/username)!)` to the entry
- Example: `- fix: fixed logprobs JSON tag in BifrostResponseChoice (thanks [@contributor](https://github.com/contributor)!)`
If multiple PRs from the same outside contributor are grouped into one entry, credit them once.
**Present the draft entries to the user for review before writing files.**
#### Per-Module changelog.md (core, framework, plugins)
Write simple flat-list entries to each module's `changelog.md`:
```markdown
- fix: description of what was fixed
- feat: description of new feature
- hotfix: description of urgent fix
```
For modules with only cascading bumps (no code changes), add a `chore:` entry describing which upstream dependencies were bumped. **No changelog should ever be left empty.** Example:
```markdown
- chore: upgraded core to v1.5.0 and framework to v1.3.0
```
If only one upstream changed, mention just that one (e.g., `- chore: upgraded core to v1.5.0`). Always use the actual new versions of the upstream modules.
**Formatting rules for per-module changelogs:**
- Each entry starts with `- ` followed by the type prefix and colon
- Use `fix:`, `feat:`, `hotfix:`, or `chore:` prefixes
- Breaking changes get a `<Note>` or `<Warning>` block indented under the entry
- Keep entries concise — 1 line per change unless a breaking change note is needed
#### transports/changelog.md (Enterprise-Style Format)
The transports changelog uses a categorized format with bold names. Write it using this template:
```markdown
## ✨ Features
- **Feature Name** — Description of the feature
- **Feature Name** — Description of the feature
## 🐞 Fixed
- **Bug Name** — Description of what was fixed
- **Bug Name** — Description of what was fixed
```
**Formatting rules for transports/changelog.md:**
- Use `## ✨ Features` and `## 🐞 Fixed` section headers
- Each entry uses **bold name** followed by em dash (—) and description
- Keep descriptions concise — 1-2 lines max per bullet
- Group related commits into a single bullet point
- Include changes from ALL modules (transports is the top-level summary)
- Breaking changes get a `<Warning>` or `<Note>` block indented under the entry
- Omit sections that have no entries (e.g., if there are no features, skip the Features section)
- If the release has only cascading bumps and no meaningful features or fixes, add a `## 🔧 Maintenance` section with an entry like: `- **Dependency Upgrades** — Bumped core to v1.5.0 and framework to v1.3.0 across all modules`
### Step 6: Update Version Files
Update the `version` file in each module that was bumped:
```bash
echo "{new_version}" > core/version
echo "{new_version}" > framework/version
echo "{new_version}" > transports/version
echo "{new_version}" > plugins/{plugin}/version
```
**Do NOT update go.mod files** — that is handled separately by the developer as part of the release process.
### Step 7: Present Summary
After all files are written, present a summary:
```
## Changelog Written: v{new_transport_version}
### Files Modified:
- transports/changelog.md
- core/changelog.md
- framework/changelog.md
- plugins/{changed_plugins}/changelog.md
- {list of version files updated}
### Version Bumps:
{table of old → new versions}
### Next Steps:
1. Review the changelogs
2. Update go.mod files with new dependency versions
3. Run `go mod tidy` in each module
4. Create the docs/changelogs MDX file and update docs.json manually
5. Tag the release: git tag v{new_transport_version}
```
## Error Handling
### No Changes Detected
If git diff shows no changes since the last release:
```
No changes detected since the last release (v{last_version}).
Are you sure you want to create a new changelog?
```
Ask the user to confirm or provide a different base commit/tag.
### Version Conflict
If the calculated new version already has a changelog file in docs:
```
A changelog for v{version} already exists at docs/changelogs/v{version}.mdx.
Would you like to:
1. Continue anyway (version files and changelog.md will be overwritten)
2. Choose a different version number
```
### Missing Module Version File
If a version file is missing:
```bash
# Fallback: read version from go.mod
grep "^module" {module}/go.mod
```
Ask the user what version to use.
## Project Directory Reference
```
bifrost/
├── core/
│ ├── version # Plain text: "1.5.0"
│ ├── changelog.md # Simple flat-list format
│ └── go.mod
├── framework/
│ ├── version # Plain text: "1.3.0"
│ ├── changelog.md # Simple flat-list format
│ └── go.mod
├── plugins/
│ ├── governance/
│ │ ├── version
│ │ └── changelog.md # Simple flat-list format
│ ├── jsonparser/version
│ ├── litellmcompat/version
│ ├── logging/
│ │ ├── version
│ │ └── changelog.md # Simple flat-list format
│ ├── maxim/version
│ ├── mocker/version
│ ├── otel/version
│ ├── semanticcache/version
│ └── telemetry/version
├── transports/
│ ├── version # Plain text: "1.5.0"
│ ├── changelog.md # Enterprise-style format (✨ Features / 🐞 Fixed)
│ └── go.mod
└── docs/
├── changelogs/ # ⚠️ DO NOT TOUCH — MDX files managed separately
└── docs.json # ⚠️ DO NOT TOUCH — navigation managed separately
```
## Plugin List (Alphabetical Order)
This is the canonical order for plugins:
1. governance
2. jsonparser
3. litellmcompat
4. logging
5. maxim
6. mocker
7. otel
8. semanticcache
9. telemetry

View File

@@ -0,0 +1,859 @@
---
name: docs-writer
description: Write, update, and review Mintlify MDX documentation for Bifrost features. Explores the full codebase (UI, Go backend, config schema), validates config.json examples, places screenshot placeholders, and presents outlines for approval before writing. Invoked with /docs-writer <feature-name>, /docs-writer update <doc-path>, or /docs-writer review <doc-path>.
allowed-tools: Read, Grep, Glob, Bash, Edit, Write, WebSearch, WebFetch, mcp__context7__resolve-library-id, mcp__context7__query-docs, Task, AskUserQuestion, TodoWrite
---
# Bifrost Documentation Writer
Write, update, and review Mintlify MDX documentation for Bifrost features. Performs thorough codebase research across both the React UI and Go backend, validates config.json examples against the schema, and follows established documentation conventions.
## Usage
```
/docs-writer <feature-name> # Write new docs for a feature
/docs-writer update <doc-path> # Update an existing doc page
/docs-writer review <doc-path> # Review a doc for accuracy and completeness
```
## Workflow Overview
1. **Understand the request** -- Determine which feature needs documentation
2. **Research the codebase** -- Explore UI pages, Go handlers, config schema, and existing docs
3. **Research external context** -- Use Context7 and WebSearch for additional information
4. **Ask clarifying questions** -- Confirm scope, audience, and edge cases with the user
5. **Present doc outline** -- Show a structured outline and wait for approval
6. **Write the documentation** -- Create the MDX file following Mintlify conventions
7. **Update navigation** -- Add the new page to docs.json
8. **Present for review** -- Show the complete doc and incorporate feedback
---
## Step 1: Understand the Request
### For new docs (`/docs-writer <feature-name>`)
Parse the feature name and map it to codebase areas. Common feature-to-directory mappings:
| Feature Name | UI Directory | Handler File | Config Schema Section | Existing Docs |
|---|---|---|---|---|
| virtual-keys | `ui/app/workspace/virtual-keys/` | `handlers/governance.go` | `governance.virtual_keys` | `docs/features/governance/virtual-keys.mdx` |
| routing | `ui/app/workspace/routing-rules/` | `handlers/governance.go` | `governance.routing_rules` | `docs/features/governance/routing.mdx` |
| providers | `ui/app/workspace/providers/` | `handlers/providers.go` | `providers` | `docs/providers/` |
| mcp | `ui/app/workspace/mcp-registry/` | `handlers/mcp.go` | `mcp` | `docs/mcp/` |
| plugins | `ui/app/workspace/plugins/` | `handlers/plugins.go` | `plugins` | `docs/features/plugins/` |
| logs / observability | `ui/app/workspace/logs/` | `handlers/logging.go` | `client.enable_logging` | `docs/features/observability/` |
| semantic-caching | `ui/app/workspace/config/caching/` | `handlers/cache.go` | `plugins.semantic_cache` + `vector_store` | `docs/features/semantic-caching.mdx` |
| guardrails | `ui/app/workspace/guardrails/` | Enterprise | `guardrails_config` | `docs/enterprise/guardrails.mdx` |
| clustering | `ui/app/workspace/cluster/` | Enterprise | `cluster_config` | `docs/enterprise/clustering.mdx` |
| load-balancing | `ui/app/workspace/adaptive-routing/` | Enterprise | `load_balancer_config` | `docs/enterprise/adaptive-load-balancing.mdx` |
| audit-logs | `ui/app/workspace/audit-logs/` | Enterprise | `audit_logs` | `docs/enterprise/audit-logs.mdx` |
| rbac | `ui/app/workspace/rbac/` | Enterprise | `auth_config` | `docs/enterprise/rbac.mdx` |
| config | `ui/app/workspace/config/` | `handlers/config.go` | `client` | `docs/quickstart/` |
| budget-and-limits | `ui/app/workspace/virtual-keys/` | `handlers/governance.go` | `governance.budgets`, `governance.rate_limits` | `docs/features/governance/budget-and-limits.mdx` |
| mcp-tools | `ui/app/workspace/mcp-tool-groups/` | `handlers/governance.go` | `governance.virtual_keys[].mcp_configs` | `docs/features/governance/mcp-tools.mdx` |
| fallbacks | N/A (request-level) | `handlers/inference.go` | N/A | `docs/features/fallbacks.mdx` |
| telemetry | `ui/app/workspace/observability/` | `handlers/plugins.go` | `plugins.prometheus`, `plugins.otel` | `docs/features/telemetry.mdx` |
If the feature name does not match any known mapping, search the codebase:
```bash
# Search UI for the feature
ls ui/app/workspace/ | grep -i "<feature>"
# Search handlers for related endpoints
grep -rn "<feature>" transports/bifrost-http/handlers/ --include='*.go' -l
# Search config schema
grep -i "<feature>" transports/config.schema.json | head -20
# Search existing docs
grep -rn "<feature>" docs/ --include='*.mdx' -l
```
### For updates (`/docs-writer update <doc-path>`)
Read the existing doc first:
```bash
cat docs/<doc-path>
```
Then identify what has changed by checking recent git history:
```bash
# Find recent code changes related to the feature
git log --oneline -20 -- 'ui/app/workspace/<feature>/' 'transports/bifrost-http/handlers/<handler>.go'
# See actual diffs
git diff HEAD~10 -- 'ui/app/workspace/<feature>/'
```
### For reviews (`/docs-writer review <doc-path>`)
Read the doc and cross-reference against the current codebase to identify:
- Outdated information (API endpoints changed, UI flow changed)
- Missing config.json fields (new schema properties not documented)
- Missing screenshots for new UI elements
- Broken internal links
- config.json examples that do not match the schema
---
## Step 2: Research the Codebase
**ALWAYS perform thorough research before writing.** This is the most critical step.
### 2a. Explore the UI Code
The UI is a React + Vite + TanStack Router application. Feature pages live under `ui/app/workspace/<feature>/`.
```bash
# List the feature directory structure
find ui/app/workspace/<feature>/ -type f -name '*.tsx' -o -name '*.ts' | sort
# Read the main page
cat ui/app/workspace/<feature>/page.tsx
# Find all views/components
ls ui/app/workspace/<feature>/views/ 2>/dev/null
ls ui/app/workspace/<feature>/dialogs/ 2>/dev/null
ls ui/app/workspace/<feature>/fragments/ 2>/dev/null
# Look for form fields, buttons, and interactive elements
grep -rn 'label\|placeholder\|data-testid\|FormField\|Input\|Select\|Button' ui/app/workspace/<feature>/ --include='*.tsx' | head -40
# Find API calls from the UI
grep -rn 'fetch\|api/\|useSWR\|mutate' ui/app/workspace/<feature>/ --include='*.tsx' --include='*.ts' | head -20
# Check shared components used
grep -rn 'import.*from.*components' ui/app/workspace/<feature>/ --include='*.tsx' | head -20
```
**What to extract from UI research:**
- Feature name as shown in the UI (page title, sidebar label)
- All form fields for create/edit operations (field names, types, validation)
- Table columns and displayed data
- Available actions (create, edit, delete, import, export)
- Navigation flow (how users reach this feature)
- Any special UI patterns (sheets, dialogs, tabs, accordions)
### 2b. Explore the Go Backend
API handlers live in `transports/bifrost-http/handlers/`.
```bash
# Find the relevant handler file
grep -rn '<feature>\|<Feature>' transports/bifrost-http/handlers/ --include='*.go' -l
# Read route registrations
grep -n '/api/' transports/bifrost-http/handlers/<handler>.go
# Read request/response types
grep -n 'type.*Request\|type.*Response' transports/bifrost-http/handlers/<handler>.go
# Read the handler functions for create/update operations
grep -n 'func.*create\|func.*update\|func.*delete\|func.*get' transports/bifrost-http/handlers/<handler>.go
```
**Complete API route reference by handler:**
| Handler File | Route Prefix | Operations |
|---|---|---|
| `governance.go` | `/api/governance/virtual-keys` | CRUD virtual keys |
| `governance.go` | `/api/governance/teams` | CRUD teams |
| `governance.go` | `/api/governance/customers` | CRUD customers |
| `governance.go` | `/api/governance/routing-rules` | CRUD routing rules |
| `governance.go` | `/api/governance/model-configs` | CRUD model configs |
| `governance.go` | `/api/governance/providers` | GET/PUT/DELETE provider governance |
| `governance.go` | `/api/governance/budgets` | GET budgets |
| `governance.go` | `/api/governance/rate-limits` | GET rate limits |
| `providers.go` | `/api/providers` | CRUD providers |
| `providers.go` | `/api/keys` | GET keys |
| `providers.go` | `/api/models` | GET models |
| `mcp.go` | `/api/mcp/clients` | GET MCP clients |
| `mcp.go` | `/api/mcp/client/{id}` | POST/PUT/DELETE MCP client |
| `logging.go` | `/api/logs` | GET/DELETE logs |
| `logging.go` | `/api/logs/stats` | GET log stats |
| `logging.go` | `/api/logs/histogram` | GET log histograms |
| `plugins.go` | `/api/plugins` | CRUD plugins |
| `config.go` | `/api/config` | GET/PUT config |
| `config.go` | `/api/proxy-config` | GET/PUT proxy config |
| `cache.go` | `/api/cache/clear/{requestId}` | DELETE cache |
| `session.go` | `/api/session/*` | Login/logout/auth check |
| `oauth2.go` | `/api/oauth/*` | OAuth callback/status |
**What to extract from backend research:**
- All API endpoints (method, path, description)
- Request body fields with types and validation rules
- Response body structure
- Error responses and status codes
- Business logic (what happens when an entity is created/updated)
### 2c. Read the Config Schema
The config schema at `transports/config.schema.json` (~2709 lines) is the source of truth for all `config.json` examples.
```bash
# Extract a specific section from the schema
cat transports/config.schema.json | python3 -c "
import json, sys
schema = json.load(sys.stdin)
# For top-level properties:
section = schema['properties'].get('<section_name>', {})
print(json.dumps(section, indent=2))
"
# For $defs references:
cat transports/config.schema.json | python3 -c "
import json, sys
schema = json.load(sys.stdin)
defn = schema.get('\$defs', {}).get('<def_name>', {})
print(json.dumps(defn, indent=2))
"
```
**Top-level config schema properties:**
- `encryption_key` - Encryption key configuration
- `auth_config` - Authentication configuration
- `client` - Client settings (logging, governance, CORS, etc.)
- `framework` - Framework configuration (pricing)
- `providers` - Provider configurations (per-provider with keys)
- `governance` - Governance (budgets, rate_limits, customers, teams, virtual_keys, routing_rules)
- `mcp` - MCP client configs and tool manager
- `vector_store` - Vector store backends (weaviate, redis, qdrant, pinecone)
- `config_store` - Config store backend (file, postgres)
- `logs_store` - Log store backend (file, postgres)
- `cluster_config` - Cluster/multinode configuration
- `scim_config` - SCIM/SSO configuration
- `load_balancer_config` - Adaptive load balancer
- `guardrails_config` - Guardrails configuration
- `plugins` - Plugin configurations
- `audit_logs` - Audit log configuration
**Key $defs (reusable types):**
- `routing_rule` - Routing rule definition
- `virtual_key_provider_config` - Provider config within a virtual key
- `virtual_key_mcp_config` - MCP config within a virtual key
- `provider` / `provider_with_bedrock_config` / `provider_with_azure_config` / `provider_with_vertex_config` - Provider definitions
- `base_key` / `bedrock_key` / `azure_key` / `vertex_key` - Key definitions
- `mcp_client_config` / `mcp_tool_manager_config` - MCP configs
- `weaviate_config` / `redis_config` / `qdrant_config` / `pinecone_config` - Vector store configs
- `proxy_config` - Proxy configuration
- `cluster_config` / `scim_config` / `load_balancer_config` / `guardrails_config` - Enterprise configs
- `pricing_config` / `network_config` / `concurrency_config` - Client sub-configs
- `audit_logs_config` - Audit logs config
**CRITICAL RULE:** Every `config.json` example in documentation MUST be validated against this schema. Extract the relevant section, verify field names, types, required fields, and allowed values before including in the doc.
### 2d. Check Existing Related Docs
```bash
# Find all docs that mention this feature
grep -rn '<feature>' docs/ --include='*.mdx' -l
# Read the most relevant existing doc
cat docs/features/<category>/<feature>.mdx
# Check cross-references
grep -rn '<feature>' docs/ --include='*.mdx' | grep -i 'link\|href\|](/\|see\|read more'
```
### 2e. Check OpenAPI Spec
If the feature has management API endpoints, check if they are in the OpenAPI spec:
```bash
ls docs/openapi/paths/management/
cat docs/openapi/paths/management/<relevant>.yaml 2>/dev/null
```
---
## Step 3: Research External Context
### 3a. Use Context7 for Library Documentation
If the feature involves external libraries or protocols:
1. Resolve the library ID:
```
mcp__context7__resolve-library-id with the library name
```
2. Query relevant documentation:
```
mcp__context7__query-docs with the resolved library ID
```
**Common libraries to research:**
- `mintlify` -- For MDX component syntax (Tabs, Info, Note, etc.)
- `mark3labs/mcp-go` -- For MCP-related features
- `react` -- For UI architecture context
- Provider SDKs -- For provider-specific features
### 3b. Use WebSearch for Additional Context
Search for:
- Official documentation of providers or protocols being documented
- Best practices for the feature pattern (e.g., "API gateway rate limiting best practices")
- Related open-source project documentation for comparison
---
## Step 4: Ask Clarifying Questions
**ALWAYS ask clarifying questions before writing.** Present what you have learned and ask:
```
## Documentation Plan for: <Feature Name>
### What I Found
- **UI pages:** <list of discovered UI pages and their purpose>
- **API endpoints:** <list of relevant endpoints>
- **Config schema fields:** <list of relevant config fields>
- **Existing docs:** <list of related docs that already exist>
### Questions
1. **Audience:** Is this doc for self-hosted OSS users, enterprise users, or both?
2. **Scope:** Should I cover <list specific sub-features>? Anything to exclude?
3. **Placement:** I plan to place this at `docs/<path>`. Does that look right?
4. **Tab coverage:** Should I include all three config methods (Web UI / API / config.json)?
5. **Related features:** Should I cross-reference <related features>?
6. <Any feature-specific questions based on ambiguities found during research>
```
Wait for user responses before proceeding.
---
## Step 5: Present Doc Outline
After clarifying questions are answered, present a structured outline:
```
## Proposed Outline: <Doc Title>
**File:** `docs/<path>/<filename>.mdx`
**Navigation:** Will be added to `docs.json` under <group> > <subgroup>
### Frontmatter
- title: "<Title>"
- description: "<Description>"
- icon: "<icon-name>"
### Sections
1. **Overview** - What the feature does, key benefits, core concepts
2. **How It Works** - Technical explanation with flow diagram
3. **Configuration** (Tabs: Web UI / API / config.json)
- Web UI steps with screenshot placeholders
- API examples with curl commands
- config.json examples (validated against schema)
4. **<Feature-specific sections>** - E.g., "Budget Hierarchy", "Rate Limiting", etc.
5. **Examples** - Real-world use cases with complete configs
6. **Troubleshooting** - Common issues and solutions
7. **Next Steps** - Links to related docs
### Screenshots Needed
- `![<Description>](../../media/ui-<feature>-<element>.png)` -- <what to capture>
- ...
### Cross-References
- [<Related Doc 1>](<path>)
- [<Related Doc 2>](<path>)
**Proceed with writing?** (yes / no / modify outline)
```
Wait for approval before writing.
---
## Step 6: Write the Documentation
### 6a. MDX File Structure
Every doc file follows this structure:
```mdx
---
title: "<Page Title>"
description: "<Short description for SEO and navigation>"
icon: "<fontawesome-icon-name>"
---
## Overview
<1-3 paragraphs explaining what the feature does and why it matters>
**Key Benefits/Features:**
- **Benefit 1** - Description
- **Benefit 2** - Description
---
## <Core Concept Section>
<Explain the core concepts, architecture, or flow>
```mermaid
graph LR
A[Request] --> B{Check}
B --> C[Result]
```
---
## Configuration
<Tabs group="config-method">
<Tab title="Web UI">
1. Step-by-step instructions
2. With numbered steps
![Description of UI Element](../../media/ui-<feature>-<element>.png)
3. Continue steps after screenshot
</Tab>
<Tab title="API">
```bash
curl -X POST http://localhost:8080/api/<endpoint> \
-H "Content-Type: application/json" \
-d '{
"field": "value"
}'
```
**Response:**
```json
{
"message": "Success",
"data": {}
}
```
</Tab>
<Tab title="config.json">
```json
{
"<section>": {
"<field>": "<value>"
}
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `field` | string | Yes | Description |
</Tab>
</Tabs>
---
## <Additional Sections>
<Info>
Important information the user should know.
</Info>
<Note>
Caveats or edge cases to be aware of.
</Note>
<Warning>
Dangerous or irreversible operations.
</Warning>
---
## Troubleshooting
### Common Issue 1
**Symptom:** ...
**Cause:** ...
**Fix:** ...
---
## Next Steps
- **[Related Feature 1](../path)** - Brief description
- **[Related Feature 2](../path)** - Brief description
```
### 6b. Screenshot Placeholder Rules
**Naming convention:** `ui-<feature>-<element>.png`
Examples from existing docs:
- `ui-virtual-key.png` - Virtual key creation form
- `ui-virtual-key-routing.png` - Virtual key routing configuration
- `ui-virtual-key-provider-config.png` - Provider config within VK
- `ui-create-teams.png` - Team creation form
- `ui-create-customer.png` - Customer creation form
- `ui-config.png` - General config page
- `ui-mcp-servers-table.png` - MCP servers listing
- `ui-mcp-new-server.png` - New MCP server form
- `ui-routing-rules-dashboard.png` - Routing rules overview
- `ui-semantic-cache-config.png` - Semantic cache settings
- `ui-tracing-config.png` - Tracing configuration
**Path in docs:** Always use relative path from the doc file location:
- From `docs/features/governance/*.mdx`: `../../media/ui-<name>.png`
- From `docs/features/*.mdx`: `../media/ui-<name>.png`
- From `docs/enterprise/*.mdx`: `../media/ui-<name>.png`
- From `docs/mcp/*.mdx`: `../media/ui-<name>.png`
- From `docs/providers/*.mdx`: `../media/ui-<name>.png`
- From `docs/quickstart/gateway/*.mdx`: `../../media/ui-<name>.png`
**Format:**
```markdown
![Descriptive Alt Text](../../media/ui-<feature>-<element>.png)
```
**Rules:**
- Alt text should describe what the screenshot shows (e.g., "Virtual Key Provider Configuration Interface")
- File name should be lowercase, hyphen-separated
- One screenshot per major UI interaction (creation form, table view, config panel, etc.)
- Place screenshot AFTER the step that leads to that UI state, not before
- Check if the screenshot already exists in `docs/media/` before creating a new placeholder
### 6c. Config.json Example Validation
**MANDATORY:** Before writing any config.json example:
1. Read the relevant schema section:
```bash
cat transports/config.schema.json | python3 -c "
import json, sys
schema = json.load(sys.stdin)
section = schema['properties']['<top_level_key>']
print(json.dumps(section, indent=2))
"
```
2. For $ref references, resolve them:
```bash
cat transports/config.schema.json | python3 -c "
import json, sys
schema = json.load(sys.stdin)
ref = schema['\$defs']['<def_name>']
print(json.dumps(ref, indent=2))
"
```
3. Verify your example includes:
- All `required` fields
- Correct field types (string, integer, number, boolean, array, object)
- Valid enum values where applicable
- Proper nesting structure
- Realistic example values (not just "string" or 0)
4. Cross-check field names against the Go handler request types:
```bash
grep -A 30 'type Create.*Request\|type Update.*Request' transports/bifrost-http/handlers/<handler>.go
```
### 6d. Mintlify Component Reference
**Callout boxes:**
```mdx
<Info>Informational content - tips, best practices, important context</Info>
<Note>Caveats, edge cases, things to be aware of</Note>
<Warning>Dangerous operations, irreversible actions, breaking changes</Warning>
<Tip>Helpful shortcuts or pro tips</Tip>
```
**Tabs (for multi-method configuration):**
```mdx
<Tabs group="config-method">
<Tab title="Web UI">
Content for Web UI method
</Tab>
<Tab title="API">
Content for API method
</Tab>
<Tab title="config.json">
Content for config.json method
</Tab>
</Tabs>
```
The `group` attribute ensures tab selection persists across the page. Use consistent group names:
- `config-method` - For Web UI / API / config.json tabs
- `sdk` or `gateway` - For Gateway / Go SDK tabs
- Feature-specific group names for feature-specific tabs
**Code blocks:**
````mdx
```bash
# Shell commands
```
```json
{
"config": "example"
}
```
```go
// Go code
```
```python
# Python code
```
```typescript
// TypeScript code
```
```mermaid
graph LR
A --> B
```
````
**Tables:**
```mdx
| Column 1 | Column 2 | Column 3 |
|----------|----------|----------|
| Value 1 | Value 2 | Value 3 |
```
### 6e. Cross-Reference Conventions
Use relative links for internal cross-references:
```mdx
[Link Text](./sibling-page)
[Link Text](../parent-dir/page)
[Link Text](/absolute/from/docs/root)
[Link Text](./page#section-anchor)
```
Common cross-reference patterns in Bifrost docs:
- Governance docs link to each other (virtual-keys, routing, budget-and-limits, mcp-tools)
- Feature docs link to the quickstart guides
- Enterprise docs link to the OSS equivalents
- All config docs link to the architecture pages
- Provider docs link to the supported providers overview
---
## Step 7: Update Navigation
After writing the doc, add it to `docs/docs.json` in the appropriate location.
### Determining Placement
The navigation structure in `docs.json` follows this hierarchy:
```
tabs (Documentation, Developer Guides, Deployment Guides, API Reference, Architecture, Benchmarks, Changelogs)
└── groups
└── pages (strings or nested groups)
```
**Placement rules:**
- OSS features go under: `Documentation > Open Source Features`
- Enterprise features go under: `Documentation > Enterprise Features`
- Provider-specific guides go under: `Documentation > Providers & Guides`
- MCP features go under: `Documentation > MCP Gateway`
- Architecture docs go under: `Architecture` tab
- Deployment guides go under: `Deployment Guides` tab
**Example: Adding a new governance feature doc:**
Find the governance group in docs.json:
```json
{
"group": "Governance",
"icon": "user-lock",
"pages": [
"features/governance/virtual-keys",
"features/governance/routing",
"features/governance/budget-and-limits",
"features/governance/mcp-tools",
"features/governance/NEW-PAGE-HERE"
]
}
```
**Page path format:** The path in docs.json is relative to `docs/` and omits the `.mdx` extension.
---
## Step 8: Present for Review
After writing the doc and updating navigation, present a summary:
```
## Documentation Complete: <Feature Name>
### Files Created/Modified
- **Created:** `docs/<path>/<filename>.mdx` (<line count> lines)
- **Modified:** `docs/docs.json` (added to <group> navigation)
### Screenshots Needed
The following screenshot placeholders were added. You will need to capture these:
1. `docs/media/ui-<feature>-<element>.png` -- <description of what to capture>
2. ...
### Config.json Validation
- All config.json examples validated against `transports/config.schema.json`
- Fields verified: <list of key fields>
- Schema sections referenced: <list>
### Cross-References Added
- Links TO this doc from: <none yet / list>
- Links FROM this doc to: <list of referenced pages>
### Review Checklist
- [ ] Frontmatter title and description are accurate
- [ ] All three config methods covered (Web UI / API / config.json)
- [ ] config.json examples match schema
- [ ] Screenshot placeholders have correct paths and descriptive alt text
- [ ] Internal links are valid
- [ ] Navigation placement in docs.json is correct
- [ ] No duplicate content with existing docs
**Would you like any changes?**
```
---
## Mandatory Rules
### Content Rules
- **ALWAYS** research the codebase before writing -- never write from assumptions
- **ALWAYS** validate config.json examples against `transports/config.schema.json`
- **ALWAYS** include all three config methods (Web UI, API, config.json) for configurable features
- **ALWAYS** use the established screenshot naming convention (`ui-<feature>-<element>.png`)
- **ALWAYS** present an outline and wait for approval before writing
- **NEVER** invent API endpoints -- verify them in the handler files
- **NEVER** guess config field names -- verify them in the schema
- **NEVER** write documentation for features that do not exist in the codebase
- **NEVER** copy content from existing docs without adapting it to the specific feature
### Style Rules
- Use sentence case for headings (e.g., "Budget management" not "Budget Management") -- but follow existing doc convention if different
- Use `**bold**` for UI element names (e.g., **Virtual Keys**, **Save** button)
- Use backticks for code references (field names, values, commands)
- Use numbered steps for sequential instructions (1, 2, 3...)
- Use bullet points for non-sequential lists
- Keep paragraphs short (2-4 sentences max)
- Use horizontal rules (`---`) to separate major sections
- Use Info/Note/Warning boxes sparingly -- only when the information is genuinely important
### Technical Rules
- API examples should use `http://localhost:8080` as the base URL
- API examples should use `curl` with proper flags (`-X`, `-H`, `-d`)
- config.json examples should be minimal but complete (include all required fields)
- Field description tables should include: Field, Type, Required, Description
- Error responses should include the HTTP status code and error body
- Reset duration format: `1m`, `5m`, `1h`, `1d`, `1w`, `1M`, `1Y`
### Navigation Rules
- Page paths in docs.json omit the `.mdx` extension
- Page paths are relative to the `docs/` directory
- New pages should be added at the end of their group unless order matters logically
- Group icons use FontAwesome icon names
---
## Project Directory Reference
```
bifrost/
├── docs/ # Mintlify documentation
│ ├── docs.json # Navigation configuration
│ ├── openapi/ # OpenAPI spec (auto-generated API reference)
│ ├── media/ # Screenshots and images
│ │ └── ui-*.png # UI screenshots (naming convention)
│ ├── features/ # Feature documentation
│ │ ├── governance/ # Virtual keys, routing, budgets, MCP tools
│ │ ├── observability/ # Logging, tracing, Prometheus, OTel
│ │ └── plugins/ # Mocker, JSON parser
│ ├── enterprise/ # Enterprise feature docs
│ ├── mcp/ # MCP Gateway documentation
│ ├── providers/ # Provider guides and supported providers
│ ├── quickstart/ # Getting started guides
│ ├── integrations/ # SDK integration guides
│ ├── plugins/ # Custom plugin development
│ ├── architecture/ # Architecture documentation
│ ├── deployment-guides/ # Deployment guides
│ ├── contributing/ # Developer contribution guides
│ ├── benchmarking/ # Performance benchmarks
│ └── changelogs/ # Version changelogs
├── ui/ # React + Vite UI application
│ └── app/workspace/ # Feature pages
│ ├── providers/ # Provider management
│ ├── virtual-keys/ # Virtual key management
│ ├── routing-rules/ # Routing rules
│ ├── logs/ # LLM request logs
│ ├── mcp-registry/ # MCP server registry
│ ├── mcp-logs/ # MCP tool execution logs
│ ├── mcp-tool-groups/ # MCP tool groups
│ ├── mcp-auth-config/ # MCP OAuth configuration
│ ├── plugins/ # Plugin configuration
│ ├── observability/ # Observability settings
│ ├── dashboard/ # Analytics dashboard
│ ├── config/ # System configuration
│ ├── guardrails/ # Guardrails (enterprise)
│ ├── adaptive-routing/ # Adaptive load balancing (enterprise)
│ ├── cluster/ # Clustering (enterprise)
│ ├── rbac/ # RBAC (enterprise)
│ ├── scim/ # SCIM provisioning (enterprise)
│ ├── user-groups/ # User groups (enterprise)
│ ├── audit-logs/ # Audit logs (enterprise)
│ ├── model-limits/ # Model limits
│ ├── alert-channels/ # Alert channels
│ └── prompt-repo/ # Prompt repository
├── transports/
│ ├── config.schema.json # Configuration schema (source of truth)
│ └── bifrost-http/
│ └── handlers/ # HTTP handlers
│ ├── governance.go # Governance CRUD
│ ├── providers.go # Provider CRUD + keys + models
│ ├── mcp.go # MCP client management
│ ├── logging.go # Log queries
│ ├── plugins.go # Plugin CRUD
│ ├── config.go # Config get/update
│ ├── cache.go # Cache management
│ ├── session.go # Session/auth management
│ ├── oauth2.go # OAuth callback handling
│ ├── inference.go # LLM inference routing
│ ├── mcpinference.go # MCP inference
│ ├── mcpserver.go # MCP server (SSE/streamable)
│ ├── integrations.go # SDK integrations
│ ├── health.go # Health check
│ └── websocket.go # WebSocket handler
├── core/ # Go core library
│ ├── schemas/ # All Go types/schemas
│ ├── providers/ # Provider implementations
│ └── mcp/ # MCP protocol implementation
├── framework/ # Framework layer
│ ├── configstore/ # Config storage backends
│ └── logstore/ # Log storage backends
└── plugins/ # Go plugins
```
## Error Handling
### Feature Not Found in Codebase
If the feature name does not map to any UI page or handler:
1. Search more broadly: `grep -ri "<feature>" ui/ transports/ core/ --include='*.go' --include='*.tsx' -l`
2. Ask the user: "I could not find a UI page or API handler for '<feature>'. Can you point me to the relevant code?"
3. The feature might be code-only (Go SDK) with no UI -- adjust the doc accordingly (no Web UI tab)
### Config Schema Section Not Found
If the feature does not have a config.json section:
1. Some features are API/UI-only (no file-based config)
2. Skip the config.json tab in that case
3. Note in the doc: "This feature is configured via the Web UI or API only."
### Existing Doc Conflicts
If a doc already exists for the feature:
1. Read the existing doc thoroughly
2. Ask the user: "A doc already exists at `<path>`. Should I update it or create a new companion page?"
3. If updating, make targeted edits rather than rewriting the entire doc

View File

@@ -0,0 +1,950 @@
---
name: e2e-test
description: Write, run, debug, audit, and auto-update Playwright E2E tests for the Bifrost UI. Use when asked to create new E2E tests, add test coverage, fix flaky tests, debug failing tests, audit test correctness, update tests after UI changes, or sync tests with modified components. Invoked with /e2e-test <FEATURE_NAME>, /e2e-test fix <SPEC_FILE>, /e2e-test sync, or /e2e-test audit.
allowed-tools: Read, Grep, Glob, Bash, Edit, Write, Task, AskUserQuestion, TodoWrite
---
# Playwright E2E Testing
Write, run, debug, and auto-update Playwright E2E tests following Bifrost's established patterns and conventions. Automatically detects UI changes and updates affected tests.
## Usage
```
/e2e-test <FEATURE_NAME> # Create or update tests for a feature
/e2e-test fix <SPEC_FILE> # Debug and fix a failing test
/e2e-test run <FEATURE_NAME> # Run tests for a specific feature
/e2e-test run # Run all E2E tests
/e2e-test sync # Detect UI changes and update affected tests
/e2e-test sync <FEATURE_NAME> # Sync tests for a specific feature with UI changes
/e2e-test audit # Audit all specs for incorrect/weak assertions
/e2e-test audit <FEATURE_NAME> # Audit a specific feature's specs
```
## Workflow Overview
1. **Understand the feature** - Read the UI code to understand what needs testing
2. **Check existing tests** - Review existing test patterns for the feature or similar features
3. **Identify data-testid attributes** - Find selectors in the UI code
4. **Write/update tests** - Follow the established patterns (page objects, data factories, fixtures)
5. **Run the tests** - Execute and verify they pass
6. **Fix failures** - Debug and fix any issues
## Auto-Update Workflow (sync mode)
When invoked with `sync`, or when UI changes are detected, automatically update E2E tests:
### Step 0: Detect What Changed
Detect UI changes by checking git diff against the base branch:
```bash
# Get all changed UI files
git diff main --name-only -- 'ui/'
# Get changed files with diff content for analysis
git diff main -- 'ui/app/workspace/' 'ui/components/'
```
Categorize changes into:
- **data-testid changes** - Renamed, removed, or added test IDs
- **Route/URL changes** - Modified page routes or navigation paths
- **Component structure changes** - New/removed form fields, buttons, tables, dialogs
- **API endpoint changes** - Modified API calls that tests rely on
- **New features** - Entirely new pages or components that need test coverage
### Step 1: Map UI Changes to Affected Tests
For each changed UI file, find the corresponding test files:
```
UI File Path → Test Feature Folder
ui/app/workspace/providers/** → tests/e2e/features/providers/
ui/app/workspace/virtual-keys/** → tests/e2e/features/virtual-keys/
ui/app/workspace/dashboard/** → tests/e2e/features/dashboard/
ui/app/workspace/logs/** → tests/e2e/features/logs/
ui/app/workspace/mcp-logs/** → tests/e2e/features/mcp-logs/
ui/app/workspace/mcp-registry/** → tests/e2e/features/mcp-registry/
ui/app/workspace/routing-rules/** → tests/e2e/features/routing-rules/
ui/app/workspace/observability/** → tests/e2e/features/observability/
ui/app/workspace/config/** → tests/e2e/features/config/
ui/app/workspace/plugins/** → tests/e2e/features/plugins/
ui/components/sidebar.tsx → tests/e2e/core/pages/sidebar.page.ts
ui/components/** → May affect multiple test features
```
### Step 2: Analyze Each Change Type and Update
**A. data-testid renamed or removed:**
1. Search the old testid across all test files: `grep -r 'old-testid' tests/e2e/`
2. Update every reference in page objects, selectors, and spec files
3. If a testid was removed without replacement, check if the element still exists with a different selector and add a new testid to the UI
**B. Form fields added/removed:**
1. If a new form field was added to a create/edit form:
- Add the field to the page object's interface (e.g., `FeatureConfig`)
- Add a locator for the field in the page object constructor
- Update the `createFeature()` / `editFeature()` methods to fill the field
- Update the data factory to include the new field with a default value
- Add a test case that exercises the new field
2. If a form field was removed:
- Remove it from the interface, locators, and methods
- Remove or update test cases that depended on it
**C. New buttons/actions added:**
1. Add locators to the page object
2. Add methods to interact with the new action
3. Add test cases covering the new action
**D. Route changes:**
1. Update `goto()` methods in page objects
2. Update navigation helpers in `core/actions/navigation.ts`
3. Update any hardcoded URLs in test specs
**E. API endpoint changes:**
1. Update `core/actions/api.ts` helpers
2. Update any `waitForResponse()` URL patterns in page objects
**F. New page/feature added:**
1. Create the full test structure (page object, data factory, spec, fixture registration)
2. Follow Step 3 from the main workflow
### Step 3: Validate Updates
After making changes, run the affected tests to verify:
```bash
# Run only the affected feature tests
npx playwright test features/<affected-feature> --reporter=list
# If multiple features affected, run them all
npx playwright test features/providers features/virtual-keys --reporter=list
```
### Step 4: Report Changes
Present a summary to the user:
```
## E2E Test Sync Summary
### UI Changes Detected
- <list of changed UI files>
### Tests Updated
- **<feature>.page.ts**: Updated locators for renamed data-testid, added new field method
- **<feature>.spec.ts**: Added test for new "export" button, updated form fill sequence
- **<feature>.data.ts**: Added new field to factory defaults
### Tests Created
- **<new-feature>/**: Full test suite for new feature (page object, data, spec)
### Tests Requiring Manual Review
- <any changes that couldn't be auto-resolved, e.g., complex interaction flow changes>
### Verification
- Ran `npx playwright test features/<feature>` → X passed, Y failed
```
### Important: Proactive Sync Triggers
**ALWAYS** check for and sync E2E tests when ANY of these happen:
1. **You modify a UI component** that has `data-testid` attributes — check if tests reference those IDs
2. **You add a new `data-testid`** to a component — consider if existing tests should use it
3. **You rename or remove a component/prop** — search for test references and update them
4. **You change a form's fields** (add/remove inputs, change validation) — update page object methods and test data
5. **You modify an API route** that tests call — update API helpers and response wait patterns
6. **You change page navigation/routing** — update `goto()` methods and navigation helpers
To check quickly if tests are affected by your UI change:
```bash
# Find test files that reference any testid from the changed component
grep -rl 'data-testid-from-component' tests/e2e/
```
---
## Audit Workflow (audit mode) — Fix Incorrect Specs
Tests that pass but validate the wrong things are worse than no tests — they give false confidence. When invoked with `audit`, systematically scan specs for correctness issues and fix them.
### Step 0: Scope the Audit
```bash
# Audit all specs
/e2e-test audit
# Audit a specific feature
/e2e-test audit virtual-keys
```
If a specific feature is given, read only that feature's spec, page object, and data files. Otherwise, scan all `features/**/*.spec.ts` files.
### Step 1: Read the UI Code First
For every spec file being audited, **read the actual UI component code** to understand what the UI really does. This is the source of truth — tests must match real behavior, not assumed behavior.
```bash
# For each feature, read the UI code
# e.g. for virtual-keys:
grep -r 'data-testid' ui/app/workspace/virtual-keys/ --include='*.tsx'
```
Understand:
- What fields are in the create/edit form (from the UI code, not the test)
- What the save button actually does (API call, toast, sheet close)
- What the table actually renders (columns, row content, empty state)
- What validation rules exist (required fields, format checks)
- What error states exist (API failures, permission errors)
### Step 2: Scan for Incorrect Assertion Patterns
Check each spec file for these **anti-patterns** (ordered by severity):
#### P0 — Tests That Can Never Fail (always-true assertions)
These are the most dangerous — they provide zero coverage while appearing green.
```typescript
// WRONG: Always true — count is always >= 0
const count = await page.getCount()
expect(count >= 0).toBe(true)
// WRONG: Always true — empty string is a string
const text = await element.textContent()
expect(typeof text).toBe('string')
// WRONG: Always true — isVisible returns a boolean, not asserted correctly
const visible = await element.isVisible()
// (no assertion at all, or expect(visible).toBeDefined())
// WRONG: Catching error means it never fails
try { await doSomething(); expect(true).toBe(true) } catch { expect(true).toBe(true) }
```
**Fix:** Replace with deterministic assertions that verify actual expected state.
#### P1 — Tests That Assert Existence But Not Correctness
The test creates an item and checks it exists, but never verifies the item has the right data.
```typescript
// WEAK: Only checks existence, not content
await page.createVirtualKey({ name: 'Test VK', description: 'My desc', budget: { maxLimit: 100 } })
const exists = await page.virtualKeyExists('Test VK')
expect(exists).toBe(true)
// Never checks that description, budget, or other fields were actually saved correctly
```
**Fix:** After create, open/view the item and verify its fields match what was submitted. Compare against the actual UI state:
```typescript
// BETTER: Verify the data was saved correctly
await page.viewVirtualKey(vkData.name)
await expect(page.descriptionInput).toHaveValue('My desc')
await expect(page.page.locator('#budgetMaxLimit')).toHaveValue('100')
```
#### P2 — Tests That Assert the Wrong Thing
The test name says one thing but asserts something unrelated.
```typescript
// WRONG: Test says "should validate email" but only checks button state
test('should validate email format', async ({ page }) => {
await page.fillEmail('invalid')
await expect(page.saveBtn).toBeDisabled() // Tests button, not validation message
})
// WRONG: Test says "should delete" but only checks toast, not actual deletion
test('should delete item', async ({ page }) => {
await page.deleteItem('foo')
await page.waitForSuccessToast()
// Never checks the item is actually gone from the table
})
```
**Fix:** Align assertions with test intent. A delete test must verify the item is gone. A validation test must check the validation message.
#### P3 — Tests With Swallowed Errors / Catch-All Handlers
Tests that catch errors and silently continue, hiding real failures.
```typescript
// WRONG: Error is caught and ignored — test always passes
const isVisible = await element.isVisible().catch(() => false)
if (isVisible) {
expect(isVisible).toBe(true) // Only asserts when visible, silently passes when not
}
// WRONG: Optional assertion that can be skipped entirely
const providerSection = page.getByText(/Providers/i).first()
const isProviderVisible = await providerSection.isVisible().catch(() => false)
if (isProviderVisible) {
expect(isProviderVisible).toBe(true) // Tautology when reached, skipped when not
}
```
**Fix:** Remove the catch/conditional and make the assertion unconditional. If the element should be visible, assert it directly:
```typescript
await expect(element).toBeVisible()
```
If the state genuinely depends on external factors, use count-based branching with **both** branches making meaningful assertions:
```typescript
const count = await page.getCount()
if (count === 0) {
await expect(page.emptyState).toBeVisible()
} else {
expect(count).toBeGreaterThan(0)
await expect(page.emptyState).not.toBeVisible()
}
```
#### P4 — Tests That Don't Assert After Actions
Tests that perform actions (clicks, form fills, navigation) but have no assertion afterward.
```typescript
// WRONG: Action with no assertion — test only checks nothing throws
test('should toggle visibility', async ({ page }) => {
await page.toggleKeyVisibility('my-key')
await page.toggleKeyVisibility('my-key')
// No assertion that the visibility state actually changed
})
```
**Fix:** Add assertions verifying the action's observable effect:
```typescript
test('should toggle visibility', async ({ page }) => {
await page.toggleKeyVisibility('my-key')
// Verify key value is now visible
await expect(page.getKeyValueText('my-key')).toBeVisible()
await page.toggleKeyVisibility('my-key')
// Verify key value is now hidden again
await expect(page.getKeyValueText('my-key')).not.toBeVisible()
})
```
#### P5 — Tests Asserting Against Stale State
Tests that read state before an action completes, or compare to a stale snapshot.
```typescript
// WRONG: Reads count before table finishes refreshing
await page.deleteItem('foo')
const count = await page.getCount() // Table hasn't refreshed yet!
expect(count).toBe(previousCount - 1) // May pass by coincidence
```
**Fix:** Wait for the state change to complete before asserting:
```typescript
await page.deleteItem('foo')
await page.waitForItemGone('foo') // Wait for table refresh
const count = await page.getCount()
expect(count).toBe(previousCount - 1)
```
#### P6 — Tests That Duplicate Other Tests Without Additional Value
Multiple tests covering the exact same code path with only cosmetic differences (different name strings, same logic).
```typescript
// REDUNDANT: These three tests do the same thing with different budget values
test('should create VK with small budget', ...) // creates + checks exists
test('should create VK with medium budget', ...) // creates + checks exists
test('should create VK with daily budget', ...) // creates + checks exists
// None of them verify the budget value was saved correctly
```
**Fix:** Either consolidate into a parameterized test, or make each test verify something unique (e.g., verify the actual budget value appears in the UI).
### Step 3: Cross-Check Against UI Code
For each test, verify:
1. **Form fields match** - Does the test fill all required fields from the UI? Does it test fields that actually exist?
2. **Selectors are correct** - Does `data-testid="vk-name-input"` actually exist in the current UI code? Has it been renamed?
3. **Behavior matches** - If the UI shows a confirmation dialog on delete, does the test handle it? If the form has validation, do tests exercise it?
4. **Error paths exist** - Does the UI show error toasts? Are there tests for error scenarios?
5. **New UI capabilities untested** - Has the UI added new features (export, filter, sort, pagination) that have no test coverage?
```bash
# Compare what testids exist in UI vs what tests reference
grep -roh 'data-testid="[^"]*"' ui/app/workspace/<feature>/ | sort -u > /tmp/ui-testids.txt
grep -roh "getByTestId('[^']*')\|getByTestId(\"[^\"]*\")" tests/e2e/features/<feature>/ | sort -u > /tmp/test-testids.txt
# Diff to find gaps
diff /tmp/ui-testids.txt /tmp/test-testids.txt
```
### Step 4: Fix and Strengthen
For each issue found:
1. **Read the UI code** for that specific component/interaction
2. **Understand the expected behavior** from the UI implementation
3. **Rewrite the assertion** to verify actual, observable, correct state
4. **Run the fixed test** to ensure it passes with correct behavior and **would fail** if the behavior broke
### Step 5: Report Findings
Present results to the user:
```
## E2E Audit Report — <feature>
### Issues Found: X
#### P0 — Always-True Assertions (X found)
| Test | File:Line | Issue | Fix |
|------|-----------|-------|-----|
| "should show empty state" | vk.spec.ts:374 | `count >= 0` always true | Use count-based branching |
#### P1 — Missing Data Verification (X found)
| Test | File:Line | Issue | Fix |
|------|-----------|-------|-----|
| "should create with budget" | vk.spec.ts:108 | Only checks `exists`, not budget value | Add field verification after create |
#### P2 — Wrong Assertion Target (X found)
...
#### P3 — Swallowed Errors (X found)
...
#### P4 — Missing Assertions (X found)
...
#### P5 — Stale State (X found)
...
#### P6 — Redundant Tests (X found)
...
### Coverage Gaps
- No test for: <UI feature that exists but has no test>
- Missing error path test for: <error scenario>
### Summary
- X tests fixed
- X tests need manual review (complex interaction flows)
- X new tests added for coverage gaps
```
---
## Project Structure
All E2E tests live in `tests/e2e/`:
```
tests/e2e/
├── playwright.config.ts # Playwright configuration
├── global-setup.ts # Global setup (plugin build, MCP servers, Bifrost connectivity)
├── core/ # Shared utilities & fixtures
│ ├── fixtures/
│ │ ├── base.fixture.ts # Main fixture - exports `test` and `expect` with all page objects
│ │ └── test-data.fixture.ts # TestDataFactory for generating unique test data
│ ├── pages/
│ │ ├── base.page.ts # BasePage class with common methods (toasts, forms, waits)
│ │ └── sidebar.page.ts # Sidebar navigation
│ ├── actions/
│ │ ├── navigation.ts # Navigation helpers (goToProviders, goToVirtualKeys, etc.)
│ │ └── api.ts # API helpers for setup/cleanup (providersApi, virtualKeysApi, etc.)
│ └── utils/
│ ├── selectors.ts # Centralized selector definitions
│ └── test-helpers.ts # Utilities: waitForNetworkIdle, retry, fillSelect, assertToast, etc.
└── features/ # One folder per feature
└── <feature>/
├── <feature>.spec.ts # Test cases
├── <feature>.data.ts # Test data factories & sample constants
└── pages/
└── <feature>.page.ts # Page object extending BasePage
```
## Step 1: Understand the Feature
Before writing tests, read the relevant UI code to understand:
- What pages/routes exist for the feature
- What `data-testid` attributes are already in the UI components
- What CRUD operations are available
- What form fields, buttons, and interactive elements exist
- What API endpoints the UI calls
**Search for data-testid in UI code:**
```bash
# Find all data-testid attributes for a feature
grep -r 'data-testid' ui/app/workspace/<feature>/ --include='*.tsx' --include='*.ts'
```
**Check what routes exist:**
```bash
ls ui/app/workspace/
```
## Step 2: Check Existing Tests
Always review existing patterns before writing new tests:
```bash
# List all existing feature test folders
ls tests/e2e/features/
# Read an existing spec for patterns
cat tests/e2e/features/virtual-keys/virtual-keys.spec.ts
# Read existing page objects for patterns
cat tests/e2e/features/virtual-keys/pages/virtual-keys.page.ts
```
## Step 3: Create the Feature Test Structure
For a new feature, create these files:
### 3a. Page Object (`features/<feature>/pages/<feature>.page.ts`)
**CRITICAL RULES:**
- Always extend `BasePage`
- Define all locators in the constructor using `page.getByTestId()`
- Use `data-testid` attributes as the primary selector strategy
- Use `page.getByRole()` as a secondary strategy
- NEVER use brittle CSS selectors or chained parent locators (`.locator('..')`)
- Methods should be async and use semantic waits
- Include `goto()`, CRUD methods, and a `cleanup` method
**Template:**
```typescript
import { Locator, Page, expect } from '@playwright/test'
import { BasePage } from '../../../core/pages/base.page'
import { waitForNetworkIdle } from '../../../core/utils/test-helpers'
// Define interfaces for the feature's data
export interface FeatureConfig {
name: string
description?: string
// ... other fields
}
export class FeaturePage extends BasePage {
// Main page elements
readonly createBtn: Locator
readonly table: Locator
// Sheet/form elements
readonly sheet: Locator
readonly nameInput: Locator
readonly saveBtn: Locator
readonly cancelBtn: Locator
constructor(page: Page) {
super(page)
// Use getByTestId for all locators
this.createBtn = page.getByTestId('create-feature-btn')
this.table = page.getByTestId('feature-table')
this.sheet = page.getByTestId('feature-sheet')
this.nameInput = page.getByTestId('feature-name-input')
this.saveBtn = page.getByTestId('feature-save-btn')
this.cancelBtn = page.getByTestId('feature-cancel-btn')
}
async goto(): Promise<void> {
await this.page.goto('/workspace/<feature>')
await waitForNetworkIdle(this.page)
}
async createFeature(config: FeatureConfig): Promise<void> {
await this.createBtn.click()
await expect(this.sheet).toBeVisible()
await this.waitForSheetAnimation()
// Fill form fields
await this.nameInput.fill(config.name)
// Save
await this.saveBtn.click()
await this.waitForSuccessToast()
await this.dismissToasts()
await expect(this.sheet).not.toBeVisible({ timeout: 5000 })
}
async featureExists(name: string): Promise<boolean> {
const row = this.page.getByTestId(`feature-row-${name}`)
return (await row.count()) > 0
}
async deleteFeature(name: string): Promise<void> {
const deleteBtn = this.page.getByTestId(`feature-delete-btn-${name}`)
await deleteBtn.click()
// Handle confirmation dialog
const confirmDialog = this.page.locator('[role="alertdialog"]')
await confirmDialog.waitFor({ state: 'visible', timeout: 5000 })
const confirmBtn = confirmDialog.getByRole('button', { name: /Delete/i })
await confirmBtn.click()
await this.waitForSuccessToast()
await this.dismissToasts()
}
async closeSheet(): Promise<void> {
const isSheetVisible = await this.sheet.isVisible().catch(() => false)
if (isSheetVisible) {
const closeBtn = this.sheet.locator('button[aria-label*="close"], button:has(svg.lucide-x)').first()
if (await closeBtn.isVisible()) {
await closeBtn.click()
}
await expect(this.sheet).not.toBeVisible({ timeout: 5000 }).catch(() => {})
}
}
async cleanupFeatures(names: string[]): Promise<void> {
if (names.length === 0) return
await this.goto()
await this.closeSheet()
await this.dismissToasts()
for (const name of names) {
try {
const exists = await this.featureExists(name)
if (!exists) continue
await this.closeSheet()
await this.deleteFeature(name)
} catch (error) {
console.error(`[CLEANUP] Failed to delete: ${name}`)
}
}
}
}
```
### 3b. Test Data Factory (`features/<feature>/<feature>.data.ts`)
**CRITICAL RULES:**
- Use `Date.now()` for unique test names
- Provide factory functions with sensible defaults
- Allow partial overrides via the spread pattern
- Create sample constant objects for reusable configurations
- **Never marshal payloads to a `Record`/`Map` and re-serialize** — field ordering matters for backend validation and snapshot comparisons. Always construct payloads as object literals with fields in the intended order. Do NOT use `Object.fromEntries()`, `JSON.parse(JSON.stringify(...))` round-trips, or destructure into an intermediate `Record<string, unknown>` — these can reorder fields.
**Template:**
```typescript
import { FeatureConfig } from './pages/feature.page'
export function createFeatureData(overrides: Partial<FeatureConfig> = {}): FeatureConfig {
const timestamp = Date.now()
return {
name: `Test Feature ${timestamp}`,
description: 'E2E test feature',
// ... sensible defaults
...overrides,
}
}
// Sample configurations for different scenarios
export const SAMPLE_CONFIGS = {
basic: { /* ... */ },
advanced: { /* ... */ },
} as const
```
### 3c. Test Spec (`features/<feature>/<feature>.spec.ts`)
**CRITICAL RULES:**
- Import `test` and `expect` from `../../core/fixtures/base.fixture`
- Track created resources in arrays for cleanup in `afterEach`
- Use `test.describe()` blocks for logical grouping
- Use `test.beforeEach()` to navigate to the page
- Use `test.afterEach()` to clean up resources
- Use unique names with `Date.now()` for test data
- Write deterministic assertions (never `expect(count >= 0).toBe(true)`)
- Use `test.describe.configure({ mode: 'serial' })` when tests have write ordering dependencies
- **Never marshal API payloads to a `Record`/`Map`** — pass object literals directly to Playwright's `request.post({ data })`. Marshaling through an intermediate map can reorder fields, which breaks backend validation and snapshot comparisons.
**Template:**
```typescript
import { expect, test } from '../../core/fixtures/base.fixture'
import { createFeatureData } from './feature.data'
const createdItems: string[] = []
test.describe('Feature Name', () => {
test.beforeEach(async ({ featurePage }) => {
await featurePage.goto()
})
test.afterEach(async ({ featurePage }) => {
await featurePage.closeSheet()
if (createdItems.length > 0) {
await featurePage.cleanupFeatures([...createdItems])
createdItems.length = 0
}
})
test.describe('Creation', () => {
test('should display create button', async ({ featurePage }) => {
await expect(featurePage.createBtn).toBeVisible()
})
test('should create a basic item', async ({ featurePage }) => {
const data = createFeatureData({ name: `Basic Test ${Date.now()}` })
createdItems.push(data.name)
await featurePage.createFeature(data)
const exists = await featurePage.featureExists(data.name)
expect(exists).toBe(true)
})
})
test.describe('Deletion', () => {
test('should delete item', async ({ featurePage }) => {
const data = createFeatureData({ name: `Delete Test ${Date.now()}` })
await featurePage.createFeature(data)
let exists = await featurePage.featureExists(data.name)
expect(exists).toBe(true)
await featurePage.deleteFeature(data.name)
// No need to track for cleanup since we just deleted it
exists = await featurePage.featureExists(data.name)
expect(exists).toBe(false)
})
})
})
```
### 3d. Register the Page Object in Fixtures
If creating a brand new feature, add it to `core/fixtures/base.fixture.ts`:
1. Import the page class
2. Add to `BifrostFixtures` type
3. Add fixture definition
```typescript
// In base.fixture.ts
import { FeaturePage } from '../../features/<feature>/pages/<feature>.page'
type BifrostFixtures = {
// ... existing
featurePage: FeaturePage
}
export const test = base.extend<BifrostFixtures>({
// ... existing
featurePage: async ({ page }, use) => {
await use(new FeaturePage(page))
},
})
```
### 3e. Add npm Script (optional)
In `tests/e2e/package.json`, add a feature-specific test script:
```json
{
"scripts": {
"test:<feature>": "playwright test features/<feature>"
}
}
```
## Step 4: Run Tests
```bash
# From tests/e2e/ directory
cd tests/e2e
# Run all tests
npx playwright test
# Run specific feature
npx playwright test features/<feature>
# Run in headed mode (see the browser)
npx playwright test features/<feature> --headed
# Run with Playwright UI (interactive)
npx playwright test --ui
# Run with debug inspector
npx playwright test features/<feature> --debug
# Run a single test by title
npx playwright test -g "should create a basic item"
# From project root via Makefile
make run-e2e FLOW=<feature>
make run-e2e-headed FLOW=<feature>
```
**Environment variables:**
- `BASE_URL` - Override app URL (default: http://localhost:3000)
- `BIFROST_BASE_URL` - Override Bifrost API URL (default: http://localhost:8080)
- `SKIP_WEB_SERVER=1` - Skip auto-starting Vite dev server
- `CI=1` - Enable CI mode (retries, serial execution)
## Step 5: Debug Failing Tests
### Common Issues and Fixes
**1. Element not found / timeout:**
- Check if the `data-testid` attribute exists in the UI component
- Verify the page has loaded: add `await waitForNetworkIdle(page)` after navigation
- Check for loading spinners: `await page.locator('[data-testid="loading-spinner"]').waitFor({ state: 'hidden' })`
**2. Toast interfering with clicks:**
- Dismiss toasts before interactions: `await page.dismissToasts()` or `await page.forceCloseToasts()`
**3. Sheet/dialog animation not complete:**
- Wait for animation: `await page.waitForSheetAnimation()`
- Wait for sheet visibility: `await expect(sheet).toBeVisible()`
**4. Stale element after table refresh:**
- Re-query the locator after data changes (don't reuse old locator references)
- Use polling patterns like `waitForVirtualKeyGone()` for eventual consistency
**5. Tests pass individually but fail together:**
- Check cleanup: ensure `afterEach` deletes all created resources
- Use unique names with `Date.now()` timestamps
- Check for state pollution between test files
**6. Flaky Radix/Shadcn Select:**
- Use the `fillSelect()` helper from `core/utils/test-helpers.ts`
- Wait for `[role="listbox"]` to appear before clicking options
- Wait for `[role="listbox"]` to disappear after selection
### Debugging Tools
```bash
# View the HTML report of the last run
npx playwright show-report
# Generate test code by recording browser actions
npx playwright codegen http://localhost:3000
# Run with trace viewer (records every action)
npx playwright test --trace on
```
### Trace and Screenshots
- **Screenshots:** Taken automatically on failure, saved to `test-results/`
- **Traces:** Captured on first retry, viewable via `npx playwright show-trace <trace.zip>`
- **Videos:** Retained on failure, saved to `test-results/`
## Mandatory Rules
These rules MUST be followed at all times:
### Selectors
- **ALWAYS** use `data-testid` attributes as the primary selector strategy
- **ALWAYS** use `page.getByTestId()` or `page.getByRole()` - never raw CSS selectors for interactive elements
- **NEVER** use chained parent locators (`.locator('..')`)
- **NEVER** use `{ force: true }` on clicks - fix the underlying visibility issue instead
- If a needed `data-testid` doesn't exist in the UI, add it to the UI component first
### Waits
- **ALWAYS** use semantic waits (`waitFor()`, `expect().toBeVisible()`, `waitForLoadState()`)
- **NEVER** use `page.waitForTimeout()` except as last resort in cleanup/polling (and document why)
- **ALWAYS** wait for page load after navigation: `await waitForNetworkIdle(page)`
- **ALWAYS** wait for sheet animations before interacting with sheet contents
### Test Data
- **ALWAYS** use `Date.now()` or `TestDataFactory.uniqueId()` for unique test names
- **NEVER** use static/hardcoded test data names (causes collisions in parallel runs)
- **ALWAYS** create test data factory functions in `<feature>.data.ts`
### Cleanup
- **ALWAYS** track created resources in arrays and delete them in `afterEach`
- **ALWAYS** close open sheets before cleanup: `await page.closeSheet()`
- **ALWAYS** dismiss toasts before interactive operations
- Cleanup failures should `console.error` and continue, never throw
### Assertions
- **ALWAYS** write deterministic assertions that can actually fail
- **NEVER** write `expect(count >= 0).toBe(true)` - this always passes
- Use count-based branching for state-dependent assertions:
```typescript
if (count === 0) {
await expect(emptyState).toBeVisible()
} else {
expect(count).toBeGreaterThan(0)
}
```
### Imports
- **ALWAYS** import `test` and `expect` from `../../core/fixtures/base.fixture`
- **NEVER** import directly from `@playwright/test` in spec files (use the custom fixture)
### Adding data-testid to UI
When a UI component is missing a required `data-testid`, add it directly. Convention:
```
data-testid="<entity>-<element>-<qualifier>"
Examples:
data-testid="vk-row-{name}" # Virtual key table row
data-testid="vk-edit-btn-{name}" # Edit button for specific VK
data-testid="vk-delete-btn-{name}" # Delete button for specific VK
data-testid="create-vk-btn" # Create button
data-testid="vk-sheet" # Virtual key form sheet
data-testid="vk-name-input" # Name input in VK form
data-testid="vk-save-btn" # Save button in VK form
```
## Available BasePage Methods
Every page object inherits these from `BasePage` (`core/pages/base.page.ts`):
| Method | Description |
|--------|-------------|
| `waitForPageLoad()` | Wait for `networkidle` load state |
| `waitForChartsToLoad()` | Wait for charts/data and skeletons to disappear |
| `getToast(type?)` | Get toast locator (success/error/loading/default) |
| `waitForSuccessToast(message?)` | Wait for success toast, optionally match message |
| `waitForErrorToast(message?)` | Wait for error toast, optionally match message |
| `waitForToastsToDisappear(timeout?)` | Wait for all toasts to be gone |
| `dismissToasts()` | Wait for toasts to auto-dismiss |
| `forceCloseToasts()` | Click away + wait to force-dismiss toasts |
| `waitForSheetAnimation()` | Wait for sheet/dialog open animation to complete |
| `waitForStateChange(locator, attr, val)` | Wait for element attribute to match |
| `fillByLabel(label, value)` | Fill input by its label |
| `fillByPlaceholder(placeholder, value)` | Fill input by its placeholder |
| `fillByTestId(testId, value)` | Fill input by data-testid |
| `clickButton(text)` | Click button by visible text |
| `clickByTestId(testId)` | Click element by data-testid |
| `closeDevProfiler()` | Dismiss the Dev Profiler overlay if visible |
## Available Test Helpers (`core/utils/test-helpers.ts`)
| Helper | Description |
|--------|-------------|
| `waitForNetworkIdle(page, timeout?)` | Wait for network idle state |
| `wait(ms)` | Sleep for ms (use sparingly) |
| `retry(fn, { retries, delay })` | Retry async function with backoff |
| `randomString(length?)` | Generate random alphanumeric string |
| `uniqueTestName(prefix)` | Generate unique name with timestamp + random suffix |
| `assertToast(page, text, type)` | Assert toast appears with text |
| `assertUrl(page, pattern)` | Assert page URL matches pattern |
| `fillSelect(page, triggerSelector, optionText)` | Fill Radix/Shadcn Select component |
| `fillMultiSelect(page, inputSelector, values)` | Fill multi-select with array of values |
| `clearAndFill(page, selector, value)` | Atomically clear and fill input |
| `getTableRowCount(page, tableSelector)` | Get count of table body rows |
| `tableContainsRow(page, tableSelector, text)` | Check if table has row with text |
| `waitForTableLoad(page, tableSelector)` | Wait for table visible + spinner gone |
| `screenshotOnError(page, testName, fn)` | Auto-screenshot wrapper for debugging |
## Available API Helpers (`core/actions/api.ts`)
For programmatic setup/cleanup via API (bypassing UI):
| API | Methods |
|-----|---------|
| `providersApi` | `getAll`, `get`, `create`, `update`, `delete` |
| `virtualKeysApi` | `getAll`, `get`, `create`, `update`, `delete` |
| `teamsApi` | `getAll`, `create`, `delete` |
| `customersApi` | `getAll`, `create`, `delete` |
| `cleanupTestData(request, { virtualKeyIds, teamIds, customerIds, providerNames })` | Bulk cleanup |

1
.claude/skills/expect Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/expect

View File

@@ -0,0 +1,648 @@
---
name: investigate-issue
description: Investigate a GitHub issue by fetching details, analyzing the codebase, researching documentation, and presenting an actionable implementation plan with test guidance. Use when asked to investigate, analyze, triage, or plan work for a GitHub issue. Invoked with /investigate-issue <ISSUE_ID> or /investigate-issue (prompts for ID).
allowed-tools: Read, Grep, Glob, Bash, WebSearch, WebFetch, mcp__context7__resolve-library-id, mcp__context7__query-docs, Task, AskUserQuestion, TodoWrite, Edit, Write
---
# Investigate GitHub Issue
Fetch a GitHub issue, analyze the report, search the codebase for relevant code, research external documentation, and present a comprehensive implementation plan with side-effect analysis and test guidance.
**Your final report MUST contain all of these sections:**
1. Issue Details (from Step 1)
2. Classification (from Step 2)
3. Codebase Analysis + Documentation Research (from Step 3, including sub-step 3e)
4. Impact Analysis (from Step 4)
5. Test Plan (from Step 5)
6. Full Presentation (Step 6 template)
If any section is missing, go back and complete it before presenting the report.
## Usage
```
/investigate-issue <ISSUE_ID> # Investigate issue by number
/investigate-issue # Prompts for issue ID
```
## Workflow Overview
1. **Get the issue** -- Fetch full issue details from GitHub
2. **Classify the issue** -- Determine type (bug, feature, docs) and affected areas
3. **Search the codebase and research docs** -- Find relevant code, then research the libraries it depends on via Context7 and WebSearch
4. **Analyze impact** -- Cross-reference codebase findings with documentation to identify side effects, dependencies, and breaking changes
5. **Suggest tests** -- If changes touch `core/`, recommend specific LLM and MCP test additions
6. **Present the plan** -- Show findings and recommended changes to the user
7. **Implement with approval** -- After plan approval, make changes one at a time with user confirmation
## Step 1: Fetch the Issue
If no issue ID is provided, ask the user:
```
What is the GitHub issue number you want to investigate?
```
The repository is always `maximhq/bifrost`.
Fetch full issue details:
```bash
# Get issue with all metadata
gh issue view <ISSUE_ID> --repo maximhq/bifrost --json number,title,body,labels,assignees,state,comments,author,createdAt,updatedAt
# Get issue comments for additional context
gh issue view <ISSUE_ID> --repo maximhq/bifrost --json comments --jq '.comments[].body'
```
If the issue does not exist or `gh` fails:
- Check authentication: `gh auth status`
- Verify the issue number is valid: `gh issue list --repo maximhq/bifrost --limit 5 --json number,title`
- Report the error and ask the user for a corrected issue ID
## Step 2: Classify the Issue
### 2a. Determine Issue Type
Parse the issue title prefix and labels to classify:
| Title Prefix | Label | Type | Investigation Focus |
|---|---|---|---|
| `[Bug]:` | `bug` | Bug | Reproduce, find root cause, identify fix |
| `[Feature]:` | `enhancement` | Feature | Design approach, find insertion points |
| `[Docs]:` | `documentation` | Docs | Find affected doc pages, verify accuracy |
| (none) | (any) | General | Read body carefully, infer type from content |
### 2b. Determine Affected Areas
Use the issue's labels and body content to map to codebase areas. The issue templates include an "Affected area(s)" field with these values:
| Area Label | Codebase Directories | Key Files |
|---|---|---|
| Core (Go) | `core/`, `core/schemas/`, `core/providers/`, `core/mcp/` | `core/bifrost.go`, `core/utils.go` |
| Framework | `framework/`, `framework/configstore/`, `framework/logstore/` | `framework/config.go`, `framework/list.go` |
| Transports (HTTP) | `transports/bifrost-http/` | `transports/bifrost-http/` |
| Plugins | `plugins/` (governance, jsonparser, litellmcompat, etc.) | Plugin-specific `go.mod` files |
| UI (React) | `ui/`, `ui/app/workspace/`, `ui/components/` | Feature-specific workspace pages |
| Docs | `docs/` | `docs/docs.json`, feature-specific `.mdx` files |
If the issue body mentions specific providers (e.g., "openai", "anthropic", "gemini"), also search:
```bash
ls core/providers/
# Maps to: core/providers/<provider_name>/
```
If the issue mentions MCP, agents, or tools, also search:
```bash
ls core/mcp/
# Key: core/mcp/agent.go, core/mcp/codemode.go, core/mcp/toolmanager.go
```
### 2c. Extract Key Information
From the issue body, extract and summarize:
- **What is reported** -- The specific problem or request
- **Reproduction steps** -- If bug, how to trigger it
- **Expected vs actual behavior** -- What should happen vs what happens
- **Version info** -- Which version is affected
- **Environment details** -- OS, Go version, Node version, etc.
- **Code snippets** -- Any code the reporter included
- **Error messages** -- Stack traces, logs, error output
## Step 3: Search the Codebase
Systematically search for all code relevant to the issue. Use multiple search strategies:
### 3a. Keyword Search
Extract key terms from the issue and search:
```bash
# Search for error messages mentioned in the issue
grep -rn "exact error message" core/ framework/ transports/ ui/
# Search for function/type names mentioned
grep -rn "FunctionName\|TypeName" --include='*.go' core/
grep -rn "componentName\|functionName" --include='*.ts' --include='*.tsx' ui/
# Search for API endpoints mentioned
grep -rn "/api/endpoint" transports/ ui/
```
### 3b. Structural Search by Area
Based on the affected area, do targeted exploration:
**For Core (Go) issues:**
```bash
# Find the specific provider if mentioned
ls core/providers/<provider>/
grep -rn "relevantFunction" core/providers/<provider>/
# Check schemas for relevant types
grep -rn "TypeName" core/schemas/ --include='*.go'
# Check the main bifrost.go for relevant handlers
grep -n "functionName\|handlerName" core/bifrost.go
```
**For MCP/Agent issues:**
```bash
# Search agent code
grep -rn "keyword" core/mcp/ --include='*.go'
# Check codemode if relevant
ls core/mcp/codemode/
grep -rn "keyword" core/mcp/codemode/ --include='*.go'
```
**For UI issues:**
```bash
# Find the workspace page
ls ui/app/workspace/<feature>/
# Search for components
grep -rn "keyword" ui/app/workspace/<feature>/ --include='*.tsx' --include='*.ts'
grep -rn "keyword" ui/components/ --include='*.tsx' --include='*.ts'
# Check for data-testid attributes (relevant for E2E impact)
grep -rn 'data-testid' ui/app/workspace/<feature>/ --include='*.tsx'
```
**For Framework issues:**
```bash
grep -rn "keyword" framework/ --include='*.go'
ls framework/configstore/ framework/logstore/ framework/plugins/
```
**For Plugin issues:**
```bash
# Identify which plugin
ls plugins/
grep -rn "keyword" plugins/<plugin_name>/ --include='*.go'
```
**For Docs issues:**
```bash
# Find the affected documentation page
find docs/ -name "*.mdx" | head -30
grep -rn "keyword" docs/ --include='*.mdx'
```
### 3c. Dependency Tracing
For any function or type identified as needing changes, trace its callers and dependents:
```bash
# Find all callers of a function
grep -rn "FunctionName(" --include='*.go' core/ framework/ transports/ plugins/
# Find all implementations of an interface
grep -rn "InterfaceName" --include='*.go' core/ framework/
# Find all imports of a package
grep -rn '"github.com/maximhq/bifrost/core/schemas"' --include='*.go' .
```
### 3d. Find Related Tests
```bash
# Find existing tests for the affected code
grep -rn "TestFunctionName\|Test.*Relevant" --include='*_test.go' core/ framework/ transports/
# Check LLM tests
grep -rn "keyword" core/internal/llmtests/ --include='*.go'
# Check MCP tests
grep -rn "keyword" core/internal/mcptests/ --include='*_test.go'
# Check E2E tests if UI is affected
grep -rn "keyword" tests/e2e/ --include='*.ts'
```
### 3e. Research External Documentation
Now that you've found the relevant code, research the external libraries it depends on. This informs the impact analysis in Step 4.
**Identify libraries from the code you found:**
```bash
# Check go.mod for dependencies relevant to the issue
cat core/go.mod | grep -v "^$" | grep -v "//"
# For UI issues, check package.json
cat ui/package.json | jq '.dependencies, .devDependencies'
```
**Query Context7 for each relevant library:**
```
# Step 1: Resolve the library ID
mcp__context7__resolve-library-id(
libraryName: "<library name from go.mod or package.json>",
query: "<issue title + key terms>"
)
# Step 2: Query the docs with the resolved ID
mcp__context7__query-docs(
libraryId: "<result from step 1>",
query: "<specific question about behavior relevant to the issue>"
)
```
Common libraries: `mark3labs/mcp-go` (MCP protocol), `stretchr/testify` (test assertions), `react` (UI framework), `playwright` (E2E testing), provider SDKs (OpenAI, Anthropic, etc.)
**Search the web for additional context:**
```
WebSearch: "<error message or behavior from the issue> <library name>"
WebSearch: "<library name> best practices <relevant pattern>"
```
**Record your findings in this table** (you will copy it into the final report in Step 6):
| Source | Query Used | Key Finding | Link |
|--------|-----------|-------------|------|
| Context7: `<library>` | `<query>` | `<what was learned, or "No relevant docs found">` | `<link if available>` |
| WebSearch | `<query>` | `<what was learned, or "No relevant results">` | `<URL>` |
If no useful results are found for a library, still include a row with "No relevant documentation found" and note what was searched. This transparency helps the user understand research coverage.
## Step 4: Analyze Impact
Using both the codebase search results (Step 3a-3d) and the documentation research (Step 3e), analyze the impact of the proposed changes. For each file, note whether library documentation revealed any constraints or best practices that affect the implementation.
### 4a. Direct Changes Required
For each file that needs modification, document:
- **File path** (absolute)
- **What to change** (function, type, handler, component)
- **Why** (ties back to the issue)
- **How** (specific approach -- add parameter, modify logic, new function, etc.)
- **Library constraints** (from Step 3e research -- any API contracts, deprecations, or version requirements)
### 4b. Side Effects Analysis
For each proposed change, trace the blast radius:
**Code side effects:**
```bash
# Who calls this function?
grep -rn "FunctionToChange(" --include='*.go' core/ framework/ transports/ plugins/
# Who uses this type?
grep -rn "TypeToChange" --include='*.go' core/ framework/ transports/ plugins/
# What tests exercise this code?
grep -rn "FunctionToChange\|TestRelated" --include='*_test.go' core/ framework/ transports/
```
**Schema side effects (if changing types in core/schemas/):**
- Check all providers that implement the schema
- Check framework code that serializes/deserializes the type
- Check UI code that consumes the API response
- Check plugin code that uses the schema
**API side effects (if changing endpoints):**
- Check all UI pages that call the endpoint
- Check E2E tests that hit the endpoint
- Check documentation that references the endpoint
**UI side effects (if changing components):**
- Check all pages that use the component
- Check E2E tests with selectors targeting the component
- Check if data-testid attributes change
### 4c. Breaking Change Assessment
Classify the change:
- **Non-breaking** -- Internal refactor, bug fix with same API surface
- **Minor breaking** -- New required parameter with default, deprecation
- **Major breaking** -- Changed API signature, removed field, behavioral change
## Step 5: Test Recommendations
### 5a. General Test Guidance
For ANY code change, identify:
- Existing tests that need updating
- New test cases that should be added
- Edge cases to cover
### 5b. LLM Tests (when changes touch `core/` or `core/providers/`)
The LLM test infrastructure lives in `core/internal/llmtests/`. Key patterns:
**Test structure:**
- Each scenario is a self-contained Go file with a `Run{Scenario}Test()` function
- Signature: `func Run{Scenario}Test(t *testing.T, client *bifrost.Bifrost, ctx context.Context, testConfig ComprehensiveTestConfig)`
- Tests run against both Chat Completions and Responses APIs (dual-API testing)
**How to add a new LLM test:**
1. Create a new file in `core/internal/llmtests/` named after the scenario (e.g., `new_scenario.go`)
2. Implement `RunNewScenarioTest()` following the signature pattern
3. Register it in `core/internal/llmtests/tests.go` by adding to the `testScenarios` slice
4. Add a `Scenarios` flag in `ComprehensiveTestConfig` to enable/disable it
5. Use `validation_presets.go` expectations (e.g., `BasicChatExpectations()`, `ToolCallExpectations()`)
6. Use the retry framework from `test_retry_framework.go` for validation failures
**Example test recommendation format:**
```
New LLM test: Run{X}Test in core/internal/llmtests/{x}.go
- Scenario: <what it tests>
- Expectations: <which validation preset to use>
- Dual-API: Yes, test both Chat Completions and Responses API paths
- Register in: tests.go testScenarios slice
- Config flag: Scenarios.{X} in ComprehensiveTestConfig
```
**Running LLM tests:**
```bash
make test-core PROVIDER=<provider> TESTCASE=<TestName>
make test-core PROVIDER=<provider> PATTERN=<substring>
```
### 5c. MCP Tests (when changes touch `core/mcp/`)
The MCP test infrastructure lives in `core/internal/mcptests/`. Key patterns:
**Test structure:**
- Standard Go test functions: `func TestScenario(t *testing.T)`
- Setup via `SetupAgentTest(t, AgentTestConfig{...})` which returns `(manager, mocker, ctx)`
- Mock LLM responses via `DynamicLLMMocker` with a response queue
- Agent tests use `MockLLMCaller` with pre-defined response sequences
**How to add a new MCP test:**
1. Identify which test file category it belongs to:
- `agent_*_test.go` -- Agent loop behavior tests
- `tool_*_test.go` -- Tool execution tests
- `connection_*_test.go` -- Client connection tests
- `codemode_*_test.go` -- CodeMode-specific tests
2. Create the test function following existing patterns
3. Use `AgentTestConfig` for declarative setup:
```go
manager, mocker, ctx := SetupAgentTest(t, AgentTestConfig{
InProcessTools: []string{"echo", "calculator"},
AutoExecuteTools: []string{"*"},
MaxDepth: 5,
})
```
4. Use assertion helpers: `AssertAgentCompletedInTurns()`, `AssertToolExecutedInTurn()`
5. Use fixture helpers from `fixtures.go` for mock responses
**Example test recommendation format:**
```
New MCP test: Test{X} in core/internal/mcptests/{category}_test.go
- Category: agent | tool | connection | codemode
- Setup: AgentTestConfig with {tools} and {config}
- Mock responses: {describe the LLM response sequence}
- Assertions: {what to verify}
```
**Running MCP tests:**
```bash
make test-mcp TESTCASE=<TestName>
make test-mcp TYPE=<category> PATTERN=<substring>
```
### 5d. E2E Tests (when changes touch `ui/`)
If UI changes are involved, recommend E2E test updates following the patterns in `tests/e2e/`:
- Page objects in `tests/e2e/features/<feature>/pages/`
- Specs in `tests/e2e/features/<feature>/`
- Reference the `/e2e-test` skill for full E2E test creation workflow
- **Never marshal API payloads to a `Record`/`Map`** — construct payloads as object literals with fields in the intended order and pass directly to Playwright's `request.post({ data })`. Marshaling through intermediate maps can reorder fields, breaking backend validation and snapshot comparisons.
```bash
make run-e2e FLOW=<feature>
```
## Step 6: Present Findings
Present everything to the user in this structured format:
```
## Issue Investigation: #<ID> -- <Title>
### Issue Classification
- **Type:** Bug / Feature / Docs
- **Severity:** <from issue if present>
- **Affected areas:** <list>
- **Labels:** <list>
### Summary
<2-3 sentence summary of what the issue is about and what needs to happen>
### Codebase Analysis
#### Relevant Files Found
| File | Relevance | What Needs to Change |
|------|-----------|---------------------|
| `<absolute path>` | <why this file matters> | <specific change needed> |
| ... | ... | ... |
#### Current Behavior
<Describe what the code currently does, with code snippets>
#### Root Cause (for bugs) / Design Gap (for features)
<Explain why the issue exists>
### Documentation Research
Copy the research table from Step 3e here. If you skipped Step 3e, go back and do it now before presenting.
#### Libraries & References Consulted
| Source | Query Used | Key Finding | Link |
|--------|-----------|-------------|------|
| <from Step 3e table> | <query> | <finding> | <link> |
#### How Documentation Informs the Plan
<For each change above, note any library constraints, best practices, or API contracts discovered in Step 3e that shaped the approach. Reference specific Change numbers.>
### Implementation Plan
#### Changes Required (in order)
**Change 1: <File path>**
- **What:** <specific modification>
- **Why:** <ties to issue>
- **Function/Component:** `<name>`
- **Approach:** <how to implement>
**Change 2: <File path>**
- ...
#### Side Effects
| Change | Affected Code | Risk | Mitigation |
|--------|--------------|------|------------|
| Change 1 | `<caller/dependent>` | <risk level> | <how to mitigate> |
#### Breaking Changes
- <list any breaking changes, or "None expected">
### Test Plan
#### Existing Tests to Update
| Test | File | What to Change |
|------|------|---------------|
| `TestName` | `<path>` | <modification needed> |
#### New Tests to Add
| Test | File | What It Covers |
|------|------|---------------|
| `TestNewScenario` | `<path>` | <scenario description> |
<If changes touch core/>
#### LLM Test Additions
<Specific LLM test recommendations per Section 5b format>
#### MCP Test Additions
<Specific MCP test recommendations per Section 5c format>
</If>
<If changes touch ui/>
#### E2E Test Additions
<Recommend using /e2e-test skill for full test creation>
</If>
### Estimated Complexity
- **Scope:** Small (1-2 files) / Medium (3-5 files) / Large (6+ files)
- **Risk:** Low / Medium / High
---
**Proceed with implementation?** (yes / no / modify plan)
```
## Step 7: Implement with Per-Change Approval
Once the user approves the plan:
### 7a. Create a Todo List
Create a todo item for each change in the plan:
```
1. Change 1: <description> -- pending
2. Change 2: <description> -- pending
3. Update test: <description> -- pending
4. Add new test: <description> -- pending
5. Verify all tests pass -- pending
```
### 7b. For Each Change
Before making any edit, present the change to the user:
```
## Change <N>/<Total>: <File path>
**What:** <description of the change>
**Current code:**
<existing code that will be modified>
**Proposed change:**
<new code after modification>
**Apply this change?** (yes / no / modify)
```
Wait for user approval before applying. If user says "no", skip and move to the next change. If user says "modify", discuss and adjust.
### 7c. After All Changes
Once all approved changes are applied:
1. Run relevant tests:
```bash
# For core changes
make test-core PROVIDER=<relevant_provider> PATTERN=<relevant_test>
# For MCP changes
make test-mcp PATTERN=<relevant_test>
# For framework changes
cd framework && go test ./...
# For UI changes
make run-e2e FLOW=<feature>
```
2. Report results to the user
3. If tests fail, investigate and propose fixes (with approval)
## Error Handling
### Issue Not Found
```
Issue #<ID> was not found in maximhq/bifrost.
- Verify the issue number is correct
- Run: gh issue list --repo maximhq/bifrost --limit 10 --json number,title
```
### gh CLI Not Authenticated
```
GitHub CLI is not authenticated. Run:
gh auth login
Then retry /investigate-issue <ID>
```
### No Relevant Code Found
If codebase search yields no results:
1. Broaden the search terms
2. Search for related concepts instead of exact matches
3. Ask the user for more context about where the code might live
4. Check if this is a net-new feature with no existing code
### External Documentation Not Found
If Context7 or WebSearch yield no useful results in Step 3e:
1. Still include a row in the research table with "No relevant documentation found" and note what was searched
2. Proceed with codebase analysis alone
3. Flag areas where documentation review might be needed before implementation
## Project Directory Reference
Quick reference for navigating the Bifrost codebase:
```
bifrost/
├── core/ # Go core library
│ ├── bifrost.go # Main Bifrost implementation (~195K)
│ ├── schemas/ # All Go types/schemas
│ ├── providers/ # Provider implementations (openai, anthropic, gemini, etc.)
│ ├── mcp/ # MCP protocol implementation
│ │ ├── agent.go # Agent mode
│ │ ├── codemode/ # CodeMode (Starlark-based)
│ │ └── toolmanager.go # Tool management
│ └── internal/
│ ├── llmtests/ # LLM integration tests (~48 files)
│ │ ├── setup.go # Test initialization
│ │ ├── tests.go # Test orchestrator (scenario registry)
│ │ ├── validation_presets.go # Reusable expectations
│ │ └── test_retry_framework.go # Retry logic
│ └── mcptests/ # MCP/Agent tests (~40 files)
│ ├── setup_test.go # Test infrastructure
│ ├── agent_test_helpers.go # AgentTestConfig + SetupAgentTest
│ └── fixtures.go # Mock servers & fixtures
├── framework/ # Framework layer
│ ├── configstore/ # Configuration storage
│ ├── logstore/ # Log storage
│ ├── plugins/ # Plugin system
│ └── streaming/ # Streaming utilities
├── transports/
│ └── bifrost-http/ # HTTP transport + Docker
├── ui/ # React + Vite UI
│ ├── app/workspace/ # Feature pages
│ └── components/ # Shared components
├── plugins/ # Go plugins (governance, otel, etc.)
├── docs/ # Mintlify documentation
├── tests/e2e/ # Playwright E2E tests
└── Makefile # Build & test commands
```
## Makefile Test Commands Reference
**FOLLOW THIS EXACTLY TO RUN TESTS**
```bash
make test-core PROVIDER=<name> # Run core tests for a provider
make test-core PROVIDER=<name> TESTCASE=<X> # Run specific test
make test-core PROVIDER=<name> PATTERN=<X> # Run tests matching pattern
make test-mcp # Run all MCP tests
make test-mcp TYPE=<category> # Run MCP tests by category
make test-mcp TESTCASE=<TestName> # Run specific MCP test
make run-e2e FLOW=<feature> # Run E2E tests for feature
make run-e2e # Run all E2E tests
```

View File

@@ -0,0 +1,306 @@
---
name: resolve-pr-comments
description: Resolve all unresolved PR comments interactively. Makes local edits only—NEVER commits or pushes. Use when asked to resolve PR comments, address review feedback, handle CodeRabbit comments, or fix PR review issues. Invoked with /resolve-pr-comments <PR_NUMBER> or /resolve-pr-comments <owner/repo> <PR_NUMBER>.
allowed-tools: Read, Grep, Glob, Bash, Edit, Write, WebFetch, Task, AskUserQuestion, TodoWrite
---
# Resolve PR Comments
An interactive workflow to systematically address all unresolved PR review comments.
## Usage
```
/resolve-pr-comments <PR_NUMBER>
/resolve-pr-comments <owner/repo> <PR_NUMBER>
```
If no repo is specified, uses the current git repository's remote origin.
**Before starting the workflow** - if the flow is in Plan Model - ask if the user wants to move to default mode to solve the comments one by one. Mention that each PR resolve has planning attached to it.
## Workflow Overview
1. **Detect repository** - Get owner/repo from git remote or user input
2. **Fetch unresolved comments** - Use GitHub GraphQL API (REST doesn't expose resolved status). Paginate through `reviewThreads` (cursor-based) so all pages are checked when a PR has more than 100 threads.
3. **Create tracking file** - Maintain state across the session
4. **For each comment**:
- Get full details and any existing replies
- Show the diff view of existing code in a proper diff view
- Before suggesting the fix - do the research via documentations. And present all that docs research and relevant links to the user with the fix. Use context 7. **MAKE SURE YOU DO THIS ALWAYS**
- Present to user with options (FIX, REPLY, SKIP)
- Wait for user decision
- Execute the action
- Update tracking
5. **Verify resolution** - Check remaining unresolved count
6. **Repeat until done** - Continue until all comments resolved
## Step 1: Detect Repository
If repository not provided, detect from git remote:
```bash
git remote get-url origin | sed -E 's|.*github.com[:/]([^/]+/[^/.]+)(\.git)?|\1|'
```
## Step 2: Fetch Unresolved Comments (GraphQL)
The REST API does NOT expose resolved/unresolved status. Use GraphQL.
**Important:** `reviewThreads` returns at most 100 threads per request. PRs with many review threads (e.g. large CodeRabbit reviews) need **pagination** or you will only see the first 100 threads and miss unresolved ones on later pages. Always paginate until `pageInfo.hasNextPage` is false so the count and list are complete.
### Single-page query (first 100 threads only)
```bash
gh api graphql -f query='
{
repository(owner: "OWNER", name: "REPO") {
pullRequest(number: PR_NUMBER) {
reviewThreads(first: 100) {
pageInfo { hasNextPage endCursor }
nodes {
isResolved
comments(first: 1) {
nodes {
databaseId
path
body
author { login }
}
}
}
}
}
}
}'
```
Extract unresolved (single page):
```bash
... | jq -r '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | "\(.comments.nodes[0].databaseId)|\(.comments.nodes[0].path)|\(.comments.nodes[0].author.login)"'
```
Avoid parsing `body` in the same jq pass if you paginate—comment bodies can contain control characters and break jq. Fetch full body per comment via REST when presenting (see Step 4).
### Paginate to collect all unresolved threads
Use cursor-based pagination so every thread is considered:
1. First request: `reviewThreads(first: 100)` (no `after`).
2. From the response: read `pageInfo.hasNextPage` and `pageInfo.endCursor`.
3. Next request: `reviewThreads(first: 100, after: $endCursor)`.
4. Append unresolved from this page to your list (id, path, author only).
5. Repeat from step 2 until `hasNextPage` is false.
Example loop (collects id|path|author for all unresolved; write to a file or variable):
```bash
CURSOR=""
while true; do
if [ -z "$CURSOR" ]; then
RESP=$(gh api graphql -f query='
query {
repository(owner: "OWNER", name: "REPO") {
pullRequest(number: PR_NUMBER) {
reviewThreads(first: 100) {
pageInfo { hasNextPage endCursor }
nodes {
isResolved
comments(first: 1) {
nodes { databaseId path author { login } }
}
}
}
}
}
}')
else
RESP=$(gh api graphql -f query='
query($after: String) {
repository(owner: "OWNER", name: "REPO") {
pullRequest(number: PR_NUMBER) {
reviewThreads(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes {
isResolved
comments(first: 1) {
nodes { databaseId path author { login } }
}
}
}
}
}
}' -f after="$CURSOR")
fi
# Append unresolved from this page (id|path|author only; no body to avoid control chars)
echo "$RESP" | jq -r '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | .comments.nodes[0] | "\(.databaseId)|\(.path)|\(.author.login)"'
HAS_NEXT=$(echo "$RESP" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage')
CURSOR=$(echo "$RESP" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor // ""')
[ "$HAS_NEXT" != "true" ] || [ -z "$CURSOR" ] && break
done
```
Total unresolved count = number of lines output. Use the comment `databaseId` values to fetch full body via REST when presenting each comment (Step 4).
## Step 3: Create Tracking File
Create at `/tmp/pr-review/pr-<NUMBER>-comments.md`:
```markdown
# PR #<NUMBER> Comment Review (<owner>/<repo>)
## Summary
- Total unresolved: <count>
- Fixed: 0
- Replied: 0
- Skipped: 0
## Comments to Address
| # | ID | File | Issue | Status |
|---|-----|------|-------|--------|
| 1 | 12345 | src/foo.ts | Missing validation | pending |
## Actions Taken
| ID | Action | Details |
|----|--------|---------|
```
## Step 4: Present Each Comment
For each unresolved comment, present in this format:
```
**Comment #<N>/<TOTAL>: ID <ID> - <File>**
**What it says:**
<Summary of the comment's concern>
**Current code state:**
<Show relevant code snippet if applicable - READ THE FILE>
**Documentations referred**
For anything related to LLM calls (in /core module) - make sure you refer to the documentation. You have access to web_search and context7. and show that too
**Options:**
1. **FIX** - <Describe what the fix would be>
2. **REPLY** - <Describe the reply explaining why no fix needed>
3. **SKIP** - Move on without action
**My recommendation:** <OPTION> - <Brief reasoning>
Go ahead?
```
### Getting Full Comment Details
```bash
gh api repos/OWNER/REPO/pulls/PR_NUMBER/comments --paginate | jq -r '.[] | select(.id == COMMENT_ID) | .body'
```
### Checking for Existing Replies
```bash
gh api repos/OWNER/REPO/pulls/PR_NUMBER/comments --paginate | jq '.[] | select(.id == COMMENT_ID or .in_reply_to_id == COMMENT_ID) | {id, user: .user.login, body: (.body | gsub("\n"; " ") | .[0:150])}'
```
## Step 5: Execute Actions
**CRITICAL: Do NOT reply to PR comments until changes are pushed to the remote.** The reviewer cannot verify fixes until the code is pushed. Collect all fixes locally. This skill NEVER commits or pushes—the user handles that manually.
### For FIX:
1. Make the code change using Edit tool
2. Before applying the changes take approval from the user. DO NOT DIRECTLY MAKE CHANGE BEFORE user says yes. Also give an option to suggest the changes to code.
3. Track the fix locally in the tracking file (do NOT reply yet)
4. Continue to next comment
### For REPLY (non-code responses like "out of scope", "intentional design"):
These can be posted immediately since they don't require code verification. Use the **replies** endpoint only (see below).
## Reply endpoint (use this only)
To reply to a review comment, use the dedicated replies endpoint. **Do not** use `POST .../pulls/PR_NUMBER/comments` with `in_reply_to` — that returns 422 (in_reply_to is not a permitted key for create review comment).
```bash
gh api repos/OWNER/REPO/pulls/PR_NUMBER/comments/COMMENT_ID/replies -X POST -f body="<your reply>"
```
- `COMMENT_ID` is the numeric comment id (same as GraphQL `databaseId` from the thread's first comment).
- Request body: only `body` (string). No `in_reply_to`, `commit_id`, or path params.
## Step 5b: Push and Reply to FIX comments
After ALL comments have been addressed locally:
1. Ask user if they have pushed these changes to remote. Yes/No
2. **Only after push succeeds**, reply to each FIX comment using the replies endpoint:
```bash
gh api repos/OWNER/REPO/pulls/PR_NUMBER/comments/COMMENT_ID/replies -X POST -f body="Fixed - <description of change>. See updated code."
```
### Batch workflow (fix all → push → post all)
If the user says e.g. "resolve all comments then push then post", you may:
1. Apply all FIX and REPLY decisions locally (with user approval per comment or bulk approval).
2. Ask user to push.
3. After push, post all replies in sequence: FIX replies first, then any REPLY-only replies, using the same `.../comments/COMMENT_ID/replies` endpoint for each.
### Common Reply Templates
**Out of scope:**
```
This is a valid improvement but out of scope for this PR. Tracked for future work.
```
**Already addressed:**
```
Already addressed - <variable/file> now has <fix>. See line <N>.
```
**Intentional design:**
```
This is intentional. <Explanation of why the current approach is correct>.
```
**Different module:**
```
This comment refers to <module> which is a different module not modified in this PR. It's working as-is.
```
**Asking bot to verify:**
```
This is solved, can you check and resolve if done properly?
```
## Step 6: Verify Resolution
After addressing comments, check remaining unresolved count. If the PR has more than 100 review threads, use the same pagination loop as in Step 2 and count unresolved across all pages; a single-page query only sees the first 100 threads.
Single-page check (first 100 threads only):
```bash
gh api graphql -f query='...' | jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length'
```
If count is 0 (across all pages), report success. If comments remain:
- Some bots (like CodeRabbit) take time to auto-resolve
- User may need to push code changes first
- Re-run the workflow to address remaining comments
## Important Notes
1. **NEVER commit or push changes** - This skill only makes local edits. The user handles `git add`, `git commit`, and `git push` themselves. Do not run any git commit or git push commands.
2. **NEVER reply "Fixed" until code is pushed** - The reviewer cannot verify fixes until they're on the remote. Make all fixes locally. Only reply to FIX comments after the user confirms they have pushed (the user pushes manually).
3. **Always read the file** before suggesting fixes - understand context
4. **Check for existing replies** in the thread before responding
5. **Wait for user approval** on each action - never auto-fix without confirmation
6. **Update tracking file** after each action
7. **Some bots are slow** - CodeRabbit may take minutes to auto-resolve after push
8. **User pushes manually** - This skill never commits or pushes; the user must push code changes before expecting auto-resolution of FIX actions
## Error Handling
- If `gh` not authenticated: `gh auth login`
- If repo not found: verify owner/repo spelling
- If PR not found: verify PR number exists
- If comment ID invalid: re-fetch unresolved comments (may have been resolved)
- If reply returns 422 "in_reply_to is not a permitted key": you are using the wrong endpoint. Use `POST .../pulls/PR_NUMBER/comments/COMMENT_ID/replies` with only `-f body="..."`, not the create-comment endpoint.

87
.dockerignore Normal file
View File

@@ -0,0 +1,87 @@
# Git
.git/
.gitignore
.github/
# Node.js
node_modules/
**/node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist/
build/
out/
*.tsbuildinfo
# Logs
logs/
*.log
# Environment files
.env
.env.*
*.env
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Testing
coverage/
.nyc_output/
# Cache directories
.cache/
.parcel-cache/
# Documentation that's not needed for build
docs/
README.md
*.md
# CI/CD
ci/
# Plugin build artifacts
plugins/*/dist/
# Test directories
tests/
test/
__tests__/
# Not needed for build
cli/
terraform/
helm-charts/
npx/
nix/
recipes/
examples/
.claude/
pulse.yaml
flake.nix
# Temporary build artifacts
tmp/
**/tmp/
transports/bifrost-http/tmp/
# Temporary files
tmp/
temp/
.tmp/
# Go workspaces (local only)
go.work
go.work.sum

13
.editorconfig Normal file
View File

@@ -0,0 +1,13 @@
root = true
[*]
insert_final_newline = false
end_of_line = lf
charset = utf-8
[*.go]
indent_style = tab
indent_size = 4
[*.{js,jsx,ts,tsx,mjs,json,md,css,scss,html}]
insert_final_newline = false

12
.gitattributes vendored Normal file
View File

@@ -0,0 +1,12 @@
# Ensure shell scripts always use LF line endings
*.sh text eol=lf
# Ensure Docker entrypoint uses LF
docker-entrypoint.sh text eol=lf
# Default behavior for all other files
* text=auto
# Mark dummy credentials as generated to avoid security scanner false positives
tests/integrations/dummy-gcp-credentials.json linguist-generated=true

9
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,9 @@
.github/ @maximhq/bifrost-admin
.claude/ @maximhq/bifrost-admin
.nix/ @maximhq/bifrost-admin
Makefile @maximhq/bifrost-admin
flake.nix @maximhq/bifrost-admin
tests/scripts/ @maximhq/bifrost-admin
*.sh @maximhq/bifrost-admin
helm-charts/ @maximhq/bifrost-admin
terraform/ @maximhq/bifrost-admin

131
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,131 @@
name: Bug report
description: Report a problem or regression in Bifrost
title: "[Bug]: <short summary>"
labels: [bug]
assignees: []
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out a bug report! Please provide as much detail as possible.
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
options:
- label: I have searched existing issues and discussions to avoid duplicates
required: true
- label: I am using the latest version (or have tested against main/nightly)
required: false
- type: textarea
id: description
attributes:
label: Description
description: What happened? Include screenshots if helpful.
placeholder: Clear and concise description of the bug
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Provide a minimal, reproducible example. Link to a repo, gist, or include exact steps.
placeholder: |
1. Go to '...'
2. Run '...'
3. Observe '...'
validations:
required: true
- type: input
id: expected
attributes:
label: Expected behavior
placeholder: What did you expect to happen?
validations:
required: true
- type: input
id: actual
attributes:
label: Actual behavior
placeholder: What actually happened?
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area(s)
multiple: true
options:
- Core (Go)
- Framework
- Transports (HTTP)
- Plugins
- UI (React)
- Docs
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Affected version(s).
placeholder: e.g., v1.0.3
validations:
required: true
- type: textarea
id: env
attributes:
label: Environment
description: Include as many as apply.
placeholder: |
- OS: macOS 14.5, Linux x.y, Windows 11
- Go: 1.22.x
- Node: 20.x, npm/pnpm/yarn version
- Browser (if UI): Chrome/Firefox/Safari versions
- Bifrost components and versions (core, transports, ui)
- Any relevant environment flags/config
render: text
validations:
required: false
- type: textarea
id: logs
attributes:
label: Relevant logs/output
description: Paste error logs, stack traces, or console output.
render: shell
placeholder: |
<paste logs here>
validations:
required: false
- type: input
id: regression
attributes:
label: Regression?
description: If this worked in a previous version, which version?
placeholder: e.g., Worked in v0.8.0, broke in v0.9.0
validations:
required: false
- type: dropdown
id: severity
attributes:
label: Severity
options:
- Low (minor issue or cosmetic)
- Medium (some functionality impaired)
- High (major functionality broken)
- Critical (blocks releases or production)
validations:
required: true

2
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
blank_issues_enabled: false

45
.github/ISSUE_TEMPLATE/docs_issue.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: Documentation issue
description: Report missing, unclear, or incorrect documentation
title: "[Docs]: <short summary>"
labels: [documentation]
assignees: []
body:
- type: markdown
attributes:
value: |
Help us improve the docs! Please provide links and suggestions.
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
options:
- label: I have searched existing issues and docs to avoid duplicates
required: true
- type: input
id: page
attributes:
label: Affected page(s)
description: Provide the path or URL to the affected doc(s)
placeholder: docs/usage/providers.md or https://...
validations:
required: true
- type: textarea
id: issue
attributes:
label: Whats wrong or missing?
description: Be as specific as possible.
validations:
required: true
- type: textarea
id: suggestion
attributes:
label: Suggested change
description: Propose wording or structure improvements.
validations:
required: false

View File

@@ -0,0 +1,69 @@
name: Feature request
description: Suggest an idea or enhancement for Bifrost
title: "[Feature]: <short summary>"
labels: [enhancement]
assignees: []
body:
- type: markdown
attributes:
value: |
Thanks for proposing a feature! Please fill out the details below.
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
options:
- label: I have searched existing issues and discussions to avoid duplicates
required: true
- type: textarea
id: problem
attributes:
label: Problem to solve
description: What problem does this feature solve? Who benefits?
placeholder: Describe the problem clearly.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: Describe your proposed API/UX/CLI. Include examples if helpful.
placeholder: Provide details about how this should work.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: What other solutions or workarounds did you consider?
validations:
required: false
- type: dropdown
id: area
attributes:
label: Area(s)
multiple: true
options:
- Core (Go)
- Framework
- Transports (HTTP)
- Plugins
- UI (React)
- Docs
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional context
description: Add any other context, sketches, or references here.
validations:
required: false

BIN
.github/assets/bifrost-logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
.github/assets/book-demo-button.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
.github/assets/features.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

266
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,266 @@
version: 2
updates:
- package-ecosystem: "docker"
directory: "/transports"
schedule:
interval: "weekly"
open-pull-requests-limit: 0
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 0
# Go / npm / Rust (daily). Docker + GitHub Actions: weekly entries above.
- package-ecosystem: gomod
directory: /cli
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /core
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /examples/mcps/auth-demo-server
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /examples/mcps/edge-case-server
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: npm
directory: /examples/mcps/edge-case-server
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /examples/mcps/error-test-server
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: npm
directory: /examples/mcps/error-test-server
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /examples/mcps/go-test-server
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /examples/mcps/http-no-ping-server
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /examples/mcps/parallel-test-server
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: npm
directory: /examples/mcps/parallel-test-server
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: npm
directory: /examples/mcps/temperature
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: npm
directory: /examples/mcps/test-tools-server
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /examples/plugins/hello-world-wasm-go
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: cargo
directory: /examples/plugins/hello-world-wasm-rust
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: npm
directory: /examples/plugins/hello-world-wasm-typescript
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /examples/plugins/hello-world
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /examples/plugins/http-transport-only
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /examples/plugins/llm-only
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /examples/plugins/mcp-only
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /examples/plugins/multi-interface
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /framework
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: npm
directory: /npx/bifrost-cli
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: npm
directory: /npx/bifrost
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /plugins/governance
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /plugins/jsonparser
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /plugins/litellmcompat
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /plugins/logging
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /plugins/maxim
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /plugins/mocker
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /plugins/otel
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /plugins/semanticcache
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /plugins/telemetry
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: npm
directory: /tests/e2e/api/newman-reporter-dbverify
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: npm
directory: /tests/e2e/api
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: npm
directory: /tests/e2e
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /tests/governance
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: npm
directory: /tests/integrations/typescript
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /tests/scripts/1millogs
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /tests/scripts/migration-checker
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: gomod
directory: /transports
schedule:
interval: daily
open-pull-requests-limit: 0
- package-ecosystem: npm
directory: /ui
schedule:
interval: daily
open-pull-requests-limit: 0

72
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,72 @@
## Summary
Briefly explain the purpose of this PR and the problem it solves.
## Changes
- What was changed and why
- Any notable design decisions or trade-offs
## Type of change
- [ ] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [ ] Core (Go)
- [ ] Transports (HTTP)
- [ ] Providers/Integrations
- [ ] Plugins
- [ ] UI (React)
- [ ] Docs
## How to test
Describe the steps to validate this change. Include commands and expected outcomes.
```sh
# Core/Transports
go version
go test ./...
# UI
cd ui
pnpm i || npm i
pnpm test || npm test
pnpm build || npm run build
```
If adding new configs or environment variables, document them here.
## Screenshots/Recordings
If UI changes, add before/after screenshots or short clips.
## Breaking changes
- [ ] Yes
- [ ] No
If yes, describe impact and migration instructions.
## Related issues
Link related issues and discussions. Example: Closes #123
## Security considerations
Note any security implications (auth, secrets, PII, sandboxing, etc.).
## Checklist
- [ ] I read `docs/contributing/README.md` and followed the guidelines
- [ ] I added/updated tests where appropriate
- [ ] I updated documentation where needed
- [ ] I verified builds succeed (Go and UI)
- [ ] I verified the CI pipeline passes locally if applicable

View File

View File

@@ -0,0 +1,57 @@
{
"$schema": "https://www.getbifrost.ai/schema",
"config_store": {
"enabled": true,
"type": "postgres",
"config": {
"host": "localhost",
"port": "5432",
"user": "bifrost",
"password": "bifrost_password",
"db_name": "bifrost",
"ssl_mode": "disable"
}
},
"logs_store": {
"enabled": true,
"type": "postgres",
"config": {
"host": "localhost",
"port": "5432",
"user": "bifrost",
"password": "bifrost_password",
"db_name": "bifrost",
"ssl_mode": "disable"
}
},
"providers": {
"openai": {
"keys": [
{
"name": "e2e-openai-key",
"value": "env.OPENAI_API_KEY",
"weight": 1,
"models": ["*"],
"use_for_batch_api": true
}
],
"network_config": {
"default_request_timeout_in_seconds": 300
}
},
"anthropic": {
"keys": [
{
"name": "e2e-anthropic-key",
"value": "env.ANTHROPIC_API_KEY",
"weight": 1,
"models": ["*"],
"use_for_batch_api": true
}
],
"network_config": {
"default_request_timeout_in_seconds": 300
}
}
}
}

View File

@@ -0,0 +1,107 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: bifrost
POSTGRES_PASSWORD: bifrost_password
POSTGRES_DB: bifrost
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bifrost -d bifrost"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- bifrost_network
weaviate:
image: cr.weaviate.io/semitechnologies/weaviate:1.32.4
command:
- --host
- 0.0.0.0
- --port
- '8080'
- --scheme
- http
environment:
- CLUSTER_HOSTNAME=weaviate
- CLUSTER_ADVERTISE_ADDR=172.38.0.12
- CLUSTER_GOSSIP_BIND_PORT=7946
- CLUSTER_DATA_BIND_PORT=7947
- DISABLE_TELEMETRY=true
- PERSISTENCE_DATA_PATH=/var/lib/weaviate
- DEFAULT_VECTORIZER_MODULE=none
- ENABLE_MODULES=
- AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED=true
- LOG_LEVEL=info
ports:
- "9000:8080"
volumes:
- weaviate_data:/var/lib/weaviate
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/v1/.well-known/ready"]
interval: 10s
timeout: 5s
retries: 5
networks:
bifrost_network:
ipv4_address: 172.38.0.12
# Redis Stack instance for vector store tests
redis-stack:
image: redis/redis-stack:7.4.0-v6
command: redis-stack-server --protected-mode no
ports:
- "6379:6379"
- "8001:8001" # RedisInsight web UI
volumes:
- redis_data:/data
networks:
bifrost_network:
ipv4_address: 172.38.0.13
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# Qdrant instance for vector store tests
qdrant:
image: qdrant/qdrant:v1.16.0
ports:
- "6333:6333" # REST API
- "6334:6334" # gRPC API
volumes:
- qdrant_data:/qdrant/storage
networks:
bifrost_network:
ipv4_address: 172.38.0.14
healthcheck:
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/6333'"]
interval: 10s
timeout: 5s
retries: 5
networks:
bifrost_network:
driver: bridge
ipam:
config:
- subnet: 172.38.0.0/16
gateway: 172.38.0.1
volumes:
postgres_data:
driver: local
weaviate_data:
driver: local
redis_data:
driver: local
qdrant_data:
driver: local

View File

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://www.getbifrost.ai/schema",
"config_store": {
"enabled": false
},
"logs_store": {
"enabled": false
}
}

View File

@@ -0,0 +1,27 @@
{
"$schema": "https://www.getbifrost.ai/schema",
"config_store": {
"enabled": true,
"type": "postgres",
"config": {
"host": "localhost",
"port": "5432",
"user": "bifrost",
"password": "bifrost_password",
"db_name": "bifrost",
"ssl_mode": "disable"
}
},
"logs_store": {
"enabled": true,
"type": "postgres",
"config": {
"host": "localhost",
"port": "5432",
"user": "bifrost",
"password": "bifrost_password",
"db_name": "bifrost",
"ssl_mode": "disable"
}
}
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "https://www.getbifrost.ai/schema",
"config_store": {
"enabled": true,
"type": "sqlite",
"config": {
"path": "../.github/workflows/configs/withconfigstore/config.db"
}
}
}

View File

@@ -0,0 +1,27 @@
{
"$schema": "https://www.getbifrost.ai/schema",
"config_store": {
"enabled": true,
"type": "postgres",
"config": {
"host": "localhost",
"port": "5432",
"user": "bifrost",
"password": "bifrost_password",
"db_name": "bifrost",
"ssl_mode": "disable"
}
},
"logs_store": {
"enabled": true,
"type": "postgres",
"config": {
"host": "localhost",
"port": "5432",
"user": "bifrost",
"password": "bifrost_password",
"db_name": "bifrost",
"ssl_mode": "disable"
}
}
}

View File

@@ -0,0 +1,17 @@
{
"$schema": "https://www.getbifrost.ai/schema",
"config_store": {
"enabled": true,
"type": "sqlite",
"config": {
"path": "../.github/workflows/configs/withconfigstorelogsstoresqlite/config.db"
}
},
"logs_store": {
"enabled": true,
"type": "sqlite",
"config": {
"path": "../.github/workflows/configs/withconfigstorelogsstoresqlite/logs.db"
}
}
}

View File

@@ -0,0 +1,17 @@
{
"$schema": "https://www.getbifrost.ai/schema",
"config_store": {
"enabled": true,
"type": "sqlite",
"config": {
"path": "../.github/workflows/configs/withdynamicplugin/config.db"
}
},
"plugins": [
{
"enabled": true,
"name": "hello-world",
"path": "../examples/plugins/hello-world/build/hello-world.so"
}
]
}

View File

@@ -0,0 +1,29 @@
{
"$schema": "https://www.getbifrost.ai/schema",
"config_store": {
"enabled": true,
"type": "sqlite",
"config": {
"path": "../.github/workflows/configs/withobservability/config.db"
}
},
"logs_store": {
"enabled": true,
"type": "sqlite",
"config": {
"path": "../.github/workflows/configs/withobservability/logs.db"
}
},
"plugins": [
{
"enabled": true,
"name": "otel",
"config": {
"service_name": "bifrost",
"collector_url": "http://localhost:4318/v1/traces",
"trace_type": "genai_extension",
"protocol": "http"
}
}
]
}

View File

@@ -0,0 +1,150 @@
{
"$schema": "https://www.getbifrost.ai/schema",
"client": {
"allow_direct_keys": false,
"allowed_origins": [
"*"
],
"disable_content_logging": false,
"drop_excess_requests": false,
"enable_logging": true,
"enforce_auth_on_inference": true,
"initial_pool_size": 300,
"log_retention_days": 365,
"max_request_body_size_mb": 100
},
"config_store": {
"enabled": true,
"type": "postgres",
"config": {
"host": "localhost",
"port": "5432",
"user": "bifrost",
"password": "bifrost_password",
"db_name": "bifrost",
"ssl_mode": "disable"
}
},
"logs_store": {
"enabled": true,
"type": "postgres",
"config": {
"host": "localhost",
"port": "5432",
"user": "bifrost",
"password": "bifrost_password",
"db_name": "bifrost",
"ssl_mode": "disable"
}
},
"mcp": {
"client_configs": [
{
"name": "WeatherService",
"connection_type": "http",
"client_id": "weather-mcp-server",
"connection_string": "http://localhost:8080/mcp"
},
{
"name": "CalendarService",
"connection_type": "http",
"client_id": "calendar-mcp-server",
"connection_string": "http://localhost:8081/mcp"
}
]
},
"governance": {
"auth_config": {
"admin_password": "env.BIFROST_ADMIN_PASSWORD",
"admin_username": "env.BIFROST_ADMIN_USERNAME",
"disable_auth_on_inference": true,
"is_enabled": false
},
"virtual_keys": [
{
"id": "vk-ai-portal-prod",
"is_active": true,
"name": "ai-portal-production-key",
"description": "Virtual key for AI portal with MCP access to weather and calendar services",
"value": "env.BIFROST_VK_AI_PORTAL",
"mcp_configs": [
{
"mcp_client_name": "WeatherService",
"tools_to_execute": [
"*"
]
},
{
"mcp_client_name": "CalendarService",
"tools_to_execute": [
"get_events",
"create_event"
]
}
],
"provider_configs": [
{
"provider": "openai",
"allowed_models": [
"*"
],
"key_ids": [
"*"
],
"weight": 1.0
}
]
},
{
"id": "vk-internal-tools",
"is_active": true,
"name": "internal-tools-key",
"description": "Virtual key for internal tools with limited MCP access",
"value": "env.BIFROST_VK_INTERNAL",
"mcp_configs": [
{
"mcp_client_name": "WeatherService",
"tools_to_execute": [
"get_current_weather"
]
}
],
"provider_configs": [
{
"provider": "openai",
"allowed_models": [
"*"
],
"key_ids": [
"*"
],
"weight": 1.0
}
]
}
]
},
"plugins": [
{
"config": {
"is_vk_mandatory": true
},
"enabled": true,
"name": "governance"
}
],
"providers": {
"openai": {
"keys": [
{
"name": "openai-primary",
"value": "env.OPENAI_API_KEY",
"weight": 1,
"models": [
"*"
]
}
]
}
}
}

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://www.getbifrost.ai/schema",
"vector_store": {
"enabled": true,
"type": "weaviate",
"config": {
"scheme": "http",
"host": "localhost:9000"
}
},
"plugins": [
{
"enabled": true,
"name": "semantic_cache",
"config": {
"dimension": 1,
"vector_store_namespace": "test"
}
}
]
}

61
.github/workflows/dependabot-alerts.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: Dependabot Alerts to Issues
on:
schedule:
- cron: "0 9 * * 1" # Weekly on Monday at 9am UTC
workflow_dispatch:
permissions:
issues: write
jobs:
create-issues:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
- name: Create issues from Dependabot alerts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
alerts=$(gh api repos/${{ github.repository }}/dependabot/alerts \
--jq '[.[] | select(.state == "open")]')
echo "$alerts" | jq -c '.[]' | while read -r alert; do
pkg=$(echo "$alert" | jq -r '.dependency.package.name')
number=$(echo "$alert" | jq -r '.number')
severity=$(echo "$alert" | jq -r '.security_advisory.severity')
summary=$(echo "$alert" | jq -r '.security_advisory.summary')
url=$(echo "$alert" | jq -r '.html_url')
ecosystem=$(echo "$alert" | jq -r '.dependency.package.ecosystem')
# Skip if issue already exists for this alert
existing=$(gh issue list \
--repo "${{ github.repository }}" \
--search "Dependabot Alert #${number}" \
--json number --jq 'length')
if [ "$existing" = "0" ]; then
gh issue create \
--repo "${{ github.repository }}" \
--title "dep: update ${pkg} (${severity})" \
--label "dependencies" \
--body "$(cat <<EOF
## Dependabot Alert #${number}
**Package:** \`${pkg}\`
**Ecosystem:** ${ecosystem}
**Severity:** ${severity}
${summary}
[View Alert](${url})
EOF
)"
fi
done

32
.github/workflows/dependency-review.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request,
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
# Once installed, if the workflow run is marked as required,
# PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.deps.dev:443
api.github.com:443
api.securityscorecards.dev:443
github.com:443
- name: 'Checkout Repository'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: 'Dependency Review'
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0

46
.github/workflows/docs-validation.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Docs Validation
on:
pull_request:
paths:
- "docs/**"
push:
branches: ["main"]
paths:
- "docs/**"
permissions:
contents: read
jobs:
check-broken-links:
name: Check Broken Links
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
github.com:443
nodejs.org:443
ph.mintlify.com:443
registry.npmjs.org:443
release-assets.githubusercontent.com:443
storage.googleapis.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "21"
- name: Check for broken links
working-directory: ./docs
run: |
echo "Checking for broken links in documentation..."
npx --yes mintlify@latest broken-links
echo "✅ No broken links found"

73
.github/workflows/e2e-tests.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: E2E Tests
on:
push:
branches: ["02-27-feat_extend_e2e_ui_tests"]
concurrency:
group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test-e2e-ui:
name: E2E UI (Playwright)
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: "1.26.2"
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "25"
- name: Set up Docker Compose
run: |
docker --version
if ! docker compose version >/dev/null 2>&1; then
echo "Installing Docker Compose plugin..."
DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker}
mkdir -p "$DOCKER_CONFIG/cli-plugins"
curl -SL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o "$DOCKER_CONFIG/cli-plugins/docker-compose"
chmod +x "$DOCKER_CONFIG/cli-plugins/docker-compose"
docker compose version
else
echo "Docker Compose plugin is available"
docker compose version
fi
- name: Run E2E UI tests
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# Optional: for SSE MCP tests (e.g. remote proxy). Set in repo secrets.
MCP_SSE_HEADERS: ${{ secrets.MCP_SSE_HEADERS }}
run: ./.github/workflows/scripts/test-e2e-ui.sh
- name: Upload Playwright artifacts
if: failure()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: playwright-report
path: |
tests/e2e/test-results/
tests/e2e/playwright-report/
retention-days: 7

129
.github/workflows/helm-release.yml vendored Normal file
View File

@@ -0,0 +1,129 @@
name: Release Helm Chart
on:
push:
branches:
- main
paths:
- "helm-charts/bifrost/**"
- ".github/workflows/helm-release.yml"
workflow_dispatch:
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
get.helm.sh:443
github.com:443
maximhq.github.io:443
proxy.golang.org:443
release-assets.githubusercontent.com:443
storage.googleapis.com:443
sum.golang.org:443
uploads.github.com:443
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
with:
version: v4.0.0
- name: Set up Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: "1.26.2"
- name: Run chart-testing (lint)
run: |
helm lint helm-charts/bifrost
- name: Validate Helm templates
run: |
chmod +x .github/workflows/scripts/validate-helm-templates.sh
.github/workflows/scripts/validate-helm-templates.sh
- name: Validate Helm config fields
run: |
chmod +x .github/workflows/scripts/validate-helm-config-fields.sh
.github/workflows/scripts/validate-helm-config-fields.sh
- name: Validate Go ↔ config.schema.json ↔ helm-chart sync (schemasync)
run: |
chmod +x .github/workflows/scripts/validate-schema-sync.sh
.github/workflows/scripts/validate-schema-sync.sh
- name: Get chart version
id: chart-version
run: |
VERSION=$(grep '^version:' helm-charts/bifrost/Chart.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Chart version: $VERSION"
- name: Check if release exists
id: check-release
run: |
if gh release view "helm-chart-v${{ steps.chart-version.outputs.version }}" &>/dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Package Helm chart
run: |
cd helm-charts
helm package bifrost
- name: Create GitHub Release
if: steps.check-release.outputs.exists == 'false'
run: |
cd helm-charts
gh release create "helm-chart-v${{ steps.chart-version.outputs.version }}" \
bifrost-${{ steps.chart-version.outputs.version }}.tgz \
--title "Helm Chart v${{ steps.chart-version.outputs.version }}" \
--notes "Helm chart release for Bifrost v${{ steps.chart-version.outputs.version }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update Helm repository index
run: |
cd helm-charts
# Download existing index if it exists
curl -sLO https://maximhq.github.io/bifrost/helm-charts/index.yaml || true
# Merge with new chart only if index.yaml exists
if [ -f index.yaml ]; then
helm repo index . --url https://github.com/maximhq/bifrost/releases/download/helm-chart-v${{ steps.chart-version.outputs.version }} --merge index.yaml
else
helm repo index . --url https://github.com/maximhq/bifrost/releases/download/helm-chart-v${{ steps.chart-version.outputs.version }}
fi
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v1.5.0'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./helm-charts
destination_dir: helm-charts
keep_files: false
enable_jekyll: false
user_name: "github-actions[bot]"
user_email: "github-actions[bot]@users.noreply.github.com"

302
.github/workflows/npx-publish.yml vendored Normal file
View File

@@ -0,0 +1,302 @@
name: NPX Package Publish
# Triggers when main is pushed and package.json has changed
on:
push:
branches:
- main
paths:
- 'npx/bifrost/package.json'
- 'npx/bifrost-cli/package.json'
# Prevent concurrent runs for the same trigger
concurrency:
group: npx-publish-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# Check if pipeline should be skipped based on first line of commit message
check-skip:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-skip: ${{ steps.check.outputs.should-skip }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check if pipeline should be skipped
id: check
run: |
COMMIT_MESSAGE=$(git log -1 --pretty=%B)
FIRST_LINE=$(echo "$COMMIT_MESSAGE" | head -n 1)
if [[ "$FIRST_LINE" == *"--skip-ci"* ]]; then
echo "should-skip=true" >> $GITHUB_OUTPUT
else
echo "should-skip=false" >> $GITHUB_OUTPUT
fi
publish-bifrost:
needs: [check-skip]
if: needs.check-skip.outputs.should-skip != 'true'
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write # Required for npm provenance
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "25"
registry-url: "https://registry.npmjs.org"
cache: "npm"
cache-dependency-path: |
npx/bifrost/package-lock.json
- name: Check if bifrost package.json changed
id: check-bifrost
run: |
if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" | grep -q '^npx/bifrost/package.json$'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
- name: Extract version from package.json
if: steps.check-bifrost.outputs.changed == 'true'
id: extract-version
run: ./.github/workflows/scripts/extract-npx-version.sh
- name: Install dependencies
if: steps.check-bifrost.outputs.changed == 'true'
working-directory: npx/bifrost
run: npm ci
- name: Run tests
if: steps.check-bifrost.outputs.changed == 'true'
working-directory: npx/bifrost
run: |
if [ -f "package.json" ] && npm run | grep -q "test"; then
echo "Running tests..."
npm test
else
echo "No tests found, skipping..."
fi
- name: Publish to npm
if: steps.check-bifrost.outputs.changed == 'true'
working-directory: npx/bifrost
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.extract-version.outputs.version }}"
echo "Publishing @maximhq/bifrost@${VERSION} to npm..."
if npm view @maximhq/bifrost@"${VERSION}" version >/dev/null 2>&1; then
echo "@maximhq/bifrost@${VERSION} already exists on npm. Skipping publish."
exit 0
fi
# Try OIDC (Trusted Publishing) first - no token needed
echo "Attempting publish with OIDC (Trusted Publishing)..."
if npm publish --provenance --access public 2>&1; then
echo "Published successfully with OIDC!"
exit 0
fi
# Fallback to NPM_TOKEN if OIDC fails
if [ -n "$NPM_TOKEN" ]; then
echo "OIDC failed, falling back to NPM_TOKEN..."
export NODE_AUTH_TOKEN="$NPM_TOKEN"
npm publish --access public
else
echo "OIDC failed and no NPM_TOKEN available"
exit 1
fi
- name: Configure Git
if: steps.check-bifrost.outputs.changed == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Create GitHub Release
if: steps.check-bifrost.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: bash .github/workflows/scripts/create-npx-release.sh "${{ steps.extract-version.outputs.version }}" "${{ steps.extract-version.outputs.full-tag }}"
- name: Discord Notification
if: always() && steps.check-bifrost.outputs.changed == 'true'
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
run: |
AUTHOR="${{ github.actor }}"
COMMIT_AUTHOR="$(git log -1 --pretty=%an || true)"
if [ -n "$COMMIT_AUTHOR" ]; then AUTHOR="$COMMIT_AUTHOR"; fi
if [ "${{ job.status }}" = "success" ]; then
TITLE="**NPX Package Published**"
STATUS="Success"
VERSION_LINE="**Version**: \`${{ steps.extract-version.outputs.version }}\`"
PACKAGE_LINE="**Package**: \`@maximhq/bifrost\`"
NPM_LINK="**[View on npm](https://www.npmjs.com/package/@maximhq/bifrost)**"
MESSAGE="$TITLE\n**Status**: $STATUS\n$VERSION_LINE\n$PACKAGE_LINE\n$NPM_LINK\n**Tag**: \`${{ steps.extract-version.outputs.full-tag }}\`\n**Commit**: \`${{ github.sha }}\`\n**Author**: ${AUTHOR}\n**[View Workflow Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})**"
else
TITLE="**NPX Package Publish Failed**"
STATUS="Failed"
MESSAGE="$TITLE\n**Status**: $STATUS\n**Tag**: \`${{ steps.extract-version.outputs.full-tag }}\`\n**Commit**: \`${{ github.sha }}\`\n**Author**: ${AUTHOR}\n**[View Workflow Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})**"
fi
payload="$(jq -n --arg content "$MESSAGE" '{content:$content}')"
curl -sS -H "Content-Type: application/json" -d "$payload" "$DISCORD_WEBHOOK"
publish-bifrost-cli:
needs: [check-skip]
if: needs.check-skip.outputs.should-skip != 'true'
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write # Required for npm provenance
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "25"
registry-url: "https://registry.npmjs.org"
- name: Check if bifrost-cli package.json changed
id: check-cli
run: |
if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" | grep -q '^npx/bifrost-cli/package.json$'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
- name: Extract version
if: steps.check-cli.outputs.changed == 'true'
id: extract-version
run: |
VERSION=$(jq -r '.version' npx/bifrost-cli/package.json)
if [[ -z "$VERSION" ]] || [[ "$VERSION" == "null" ]]; then
echo "Failed to extract version from npx/bifrost-cli/package.json"
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "full-tag=npx/bifrost-cli/v${VERSION}" >> "$GITHUB_OUTPUT"
echo "Extracted bifrost-cli version: ${VERSION}"
- name: Publish to npm
if: steps.check-cli.outputs.changed == 'true'
working-directory: npx/bifrost-cli
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ steps.extract-version.outputs.version }}"
echo "Publishing @maximhq/bifrost-cli@${VERSION} to npm..."
if npm view @maximhq/bifrost-cli@"${VERSION}" version >/dev/null 2>&1; then
echo "@maximhq/bifrost-cli@${VERSION} already exists on npm. Skipping publish."
exit 0
fi
# Try OIDC (Trusted Publishing) first - no token needed
echo "Attempting publish with OIDC (Trusted Publishing)..."
if npm publish --provenance --access public 2>&1; then
echo "Published successfully with OIDC!"
exit 0
fi
# Fallback to NPM_TOKEN if OIDC fails
if [ -n "$NPM_TOKEN" ]; then
echo "OIDC failed, falling back to NPM_TOKEN..."
export NODE_AUTH_TOKEN="$NPM_TOKEN"
npm publish --access public
else
echo "OIDC failed and no NPM_TOKEN available"
exit 1
fi
- name: Configure Git
if: steps.check-cli.outputs.changed == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Create GitHub Release
if: steps.check-cli.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ steps.extract-version.outputs.version }}"
FULL_TAG="${{ steps.extract-version.outputs.full-tag }}"
PRERELEASE_FLAG=""
if [[ "$VERSION" == *-* ]]; then
PRERELEASE_FLAG="--prerelease"
fi
if gh release view "$FULL_TAG" >/dev/null 2>&1; then
echo "Release $FULL_TAG already exists. Skipping."
exit 0
fi
if ! git rev-parse "$FULL_TAG" >/dev/null 2>&1; then
git tag "$FULL_TAG"
git push origin "$FULL_TAG"
fi
gh release create "$FULL_TAG" \
--title "Bifrost CLI v$VERSION" \
--notes "## Bifrost CLI v$VERSION
Install or run via npx:
\`\`\`bash
npx -y @maximhq/bifrost-cli
\`\`\`
- [View on npm](https://www.npmjs.com/package/@maximhq/bifrost-cli)
- [Documentation](https://docs.getbifrost.ai/quickstart/cli/getting-started)" \
--latest=false \
${PRERELEASE_FLAG}
- name: Discord Notification
if: always() && steps.check-cli.outputs.changed == 'true'
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
run: |
AUTHOR="${{ github.actor }}"
COMMIT_AUTHOR="$(git log -1 --pretty=%an || true)"
if [ -n "$COMMIT_AUTHOR" ]; then AUTHOR="$COMMIT_AUTHOR"; fi
if [ "${{ job.status }}" = "success" ]; then
MESSAGE="**NPX Bifrost CLI Published**\n**Status**: Success\n**Version**: \`${{ steps.extract-version.outputs.version }}\`\n**Package**: \`@maximhq/bifrost-cli\`\n**[View on npm](https://www.npmjs.com/package/@maximhq/bifrost-cli)**\n**Commit**: \`${{ github.sha }}\`\n**Author**: ${AUTHOR}\n**[View Workflow Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})**"
else
MESSAGE="**NPX Bifrost CLI Publish Failed**\n**Status**: Failed\n**Commit**: \`${{ github.sha }}\`\n**Author**: ${AUTHOR}\n**[View Workflow Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})**"
fi
payload="$(jq -n --arg content "$MESSAGE" '{content:$content}')"
curl -sS -H "Content-Type: application/json" -d "$payload" "$DISCORD_WEBHOOK"

65
.github/workflows/openapi-bundle.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: OpenAPI Bundle
on:
push:
branches: ["main"]
paths:
- ".github/workflows/openapi-bundle.yml"
- "docs/openapi/**"
- "!docs/openapi/openapi.json"
pull_request:
paths:
- "docs/openapi/**"
- "!docs/openapi/openapi.json"
permissions:
contents: write
jobs:
bundle-openapi:
name: Bundle OpenAPI Spec
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
files.pythonhosted.org:443
github.com:443
pypi.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ secrets.GH_TOKEN }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.11"
- name: Configure Git
run: |
git config user.name "GitHub Actions Bot"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Install dependencies
run: pip install pyyaml
- name: Bundle OpenAPI spec
working-directory: ./docs/openapi
run: python bundle.py
- name: Commit and push changes
if: github.event_name == 'push'
run: |
CURRENT_BRANCH="${GITHUB_REF_NAME:-main}"
git add docs/openapi/openapi.json
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "chore: regenerate openapi.json --skip-ci"
git push origin "$CURRENT_BRANCH"

59
.github/workflows/pr-test-notifier.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: PR Test Notifier
on:
pull_request:
types: [opened, reopened]
branches:
- main
permissions:
pull-requests: write
jobs:
# Check if pipeline should be skipped based on first line of commit message
check-skip:
runs-on: ubuntu-latest
outputs:
should-skip: ${{ steps.check.outputs.should-skip }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check if pipeline should be skipped
id: check
run: |
COMMIT_MESSAGE=$(git log -1 --pretty=%B)
FIRST_LINE=$(echo "$COMMIT_MESSAGE" | head -n 1)
if [[ "$FIRST_LINE" == *"--skip-ci"* ]]; then
echo "should-skip=true" >> $GITHUB_OUTPUT
else
echo "should-skip=false" >> $GITHUB_OUTPUT
fi
notify:
needs: [check-skip]
if: needs.check-skip.outputs.should-skip != 'true'
name: Post Test Instructions
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Post comment with test trigger instructions
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr comment ${{ github.event.pull_request.number }} \
--repo ${{ github.repository }} \
--body "## 🧪 Test Suite Available
This PR can be tested by a repository admin.
[Run tests for PR #${{ github.event.pull_request.number }}](https://github.com/${{ github.repository }}/actions/workflows/pr-tests.yml)"

163
.github/workflows/pr-tests.yml vendored Normal file
View File

@@ -0,0 +1,163 @@
name: PR Tests (Requires Approval)
on:
# Manual trigger only - requires admin to click "Run workflow" button
workflow_dispatch:
inputs:
pr_number:
description: "PR number to test (leave empty for current branch)"
required: false
type: string
# Prevent concurrent test runs on the same PR
concurrency:
group: pr-tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# Check if pipeline should be skipped based on first line of commit message
check-skip:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-skip: ${{ steps.check.outputs.should-skip }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check if pipeline should be skipped
id: check
run: |
COMMIT_MESSAGE=$(git log -1 --pretty=%B)
FIRST_LINE=$(echo "$COMMIT_MESSAGE" | head -n 1)
if [[ "$FIRST_LINE" == *"--skip-ci"* ]]; then
echo "should-skip=true" >> $GITHUB_OUTPUT
else
echo "should-skip=false" >> $GITHUB_OUTPUT
fi
# This job shows up immediately and waits for approval
run-tests:
needs: [check-skip]
if: needs.check-skip.outputs.should-skip != 'true'
name: Run Tests (Awaiting Approval)
runs-on: ubuntu-latest
# Environment with protection rules - requires admin approval
# Note: You need to configure this environment in repo settings
environment:
name: pr-testing
url: ${{ github.event.pull_request.html_url || github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: "1.26.2"
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "25"
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.11"
- name: Add comment to PR
if: github.event.pull_request.number
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr comment ${{ github.event.pull_request.number }} --body "🧪 Test run approved and starting...
**Test Suite Includes:**
- 📦 Core Build Validation
- 🔌 MCP Test Servers Build
- 🔧 Core Provider Tests
- 🛡️ Governance Tests
- 🔗 Integration Tests
[View workflow run →](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Make test script executable
run: chmod +x .github/workflows/scripts/run-tests.sh
- name: Run tests
env:
# API Keys for provider tests
MAXIM_API_KEY: ${{ secrets.MAXIM_API_KEY }}
MAXIM_LOGGER_ID: ${{ secrets.MAXIM_LOG_REPO_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }}
AWS_ARN: ${{ secrets.AWS_ARN }}
BEDROCK_API_KEY: ${{ secrets.BEDROCK_API_KEY }}
AZURE_ENDPOINT: ${{ secrets.AZURE_ENDPOINT }}
AZURE_API_KEY: ${{ secrets.AZURE_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
PARASAIL_API_KEY: ${{ secrets.PARASAIL_API_KEY }}
PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }}
ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }}
SGL_API_KEY: ${{ secrets.SGL_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
VERTEX_CREDENTIALS: ${{ secrets.VERTEX_CREDENTIALS }}
VERTEX_PROJECT_ID: ${{ secrets.VERTEX_PROJECT_ID }}
HUGGING_FACE_API_KEY: ${{ secrets.HUGGING_FACE_API_KEY }}
REPLICATE_API_KEY: ${{ secrets.REPLICATE_API_KEY }}
REPLICATE_OWNER : ${{ secrets.REPLICATE_OWNER }}
RUNWAY_API_KEY : ${{ secrets.RUNWAY_API_KEY }}
run: |
echo "Running tests for PR #${{ github.event.pull_request.number || 'manual run' }}"
./.github/workflows/scripts/run-tests.sh
- name: Report test results
if: always() && github.event.pull_request.number
env:
GH_TOKEN: ${{ github.token }}
run: |
if [ "${{ job.status }}" = "success" ]; then
gh pr comment ${{ github.event.pull_request.number }} --body "✅ **All tests passed successfully!**
All test suites have completed without errors. This PR is ready for review.
[View detailed results →](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
else
gh pr comment ${{ github.event.pull_request.number }} --body "❌ **Tests failed**
One or more test suites failed. Please review the failures and update your PR.
[View detailed results →](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
fi

138
.github/workflows/release-cli.yml vendored Normal file
View File

@@ -0,0 +1,138 @@
name: Release CLI
on:
push:
branches:
- main
# Prevent concurrent runs
concurrency:
group: release-cli
cancel-in-progress: false
permissions:
contents: read
jobs:
check-version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get-version.outputs.version }}
tag_exists: ${{ steps.check-tag.outputs.exists }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
- name: Get version from file
id: get-version
run: echo "version=$(cat cli/version)" >> "$GITHUB_OUTPUT"
- name: Check if tag exists
id: check-tag
run: |
if git rev-parse "cli/v${{ steps.get-version.outputs.version }}" >/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
test-cli:
needs: [check-version]
if: needs.check-version.outputs.tag_exists == 'false'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
- name: Set up Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: "1.26.2"
- name: Run CLI tests
working-directory: cli
run: go test ./...
release-cli:
needs: [check-version, test-cli]
if: needs.check-version.outputs.tag_exists == 'false'
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
success: ${{ steps.release.outputs.success }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.GH_TOKEN }}
- name: Set up Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: "1.26.2"
- name: Configure Git
run: |
git config user.name "GitHub Actions Bot"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Release CLI
id: release
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: ./.github/workflows/scripts/release-cli.sh "${{ needs.check-version.outputs.version }}"
push-mintlify-changelog:
needs: [check-version, release-cli]
if: needs.check-version.outputs.tag_exists == 'false' && needs.release-cli.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.GH_TOKEN }}
- name: Push Mintlify changelog
run: |
./.github/workflows/scripts/push-cli-mintlify-changelog.sh "${{ needs.check-version.outputs.version }}"

1822
.github/workflows/release-pipeline.yml vendored Normal file

File diff suppressed because it is too large Load Diff

94
.github/workflows/scorecards.yml vendored Normal file
View File

@@ -0,0 +1,94 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: '20 7 * * 2'
push:
branches: ["main"]
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
contents: read
actions: read
# To allow GraphQL ListCommits to work
issues: read
pull-requests: read
# To detect SAST tools
checks: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.deps.dev:443
api.github.com:443
api.osv.dev:443
api.scorecard.dev:443
auth.docker.io:443
fulcio.sigstore.dev:443
github.com:443
index.docker.io:443
oss-fuzz-build-logs.storage.googleapis.com:443
rekor.sigstore.dev:443
tuf-repo-cdn.sigstore.dev:443
www.bestpractices.dev:443
- name: "Checkout code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecards on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 # v3.35.1
with:
sarif_file: results.sarif

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
# Cross-compile CLI binaries for multiple platforms
# Usage: ./build-cli-executables.sh <version>
if [[ -z "${1:-}" ]]; then
echo "Usage: $0 <version>" >&2
exit 1
fi
VERSION="$1"
echo "🔨 Building CLI executables with version: $VERSION"
# Get the script directory and project root
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# Clean and create dist directory
rm -rf "$PROJECT_ROOT/dist"
mkdir -p "$PROJECT_ROOT/dist"
# Define platforms
platforms=(
"darwin/amd64"
"darwin/arm64"
"linux/amd64"
"linux/arm64"
"windows/amd64"
)
MODULE_PATH="$PROJECT_ROOT/cli"
COMMIT="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || echo 'unknown')}"
for platform in "${platforms[@]}"; do
IFS='/' read -r GOOS GOARCH <<< "$platform"
output_name="bifrost"
[[ "$GOOS" = "windows" ]] && output_name+='.exe'
echo "Building bifrost CLI for $GOOS/$GOARCH..."
mkdir -p "$PROJECT_ROOT/dist/$GOOS/$GOARCH"
cd "$MODULE_PATH"
# CLI has no CGO dependencies, so we can cross-compile without cross-compilers
env GOWORK=off CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \
go build -trimpath \
-ldflags "-s -w -buildid= -X main.version=v${VERSION} -X main.commit=${COMMIT}" \
-o "$PROJECT_ROOT/dist/$GOOS/$GOARCH/$output_name" .
# Generate SHA-256 checksum for the binary
(cd "$PROJECT_ROOT/dist/$GOOS/$GOARCH" && shasum -a 256 "$output_name" > "$output_name.sha256")
echo " → checksum: $(cat "$PROJECT_ROOT/dist/$GOOS/$GOARCH/$output_name.sha256")"
cd "$PROJECT_ROOT"
done
echo "✅ All CLI binaries built successfully"

125
.github/workflows/scripts/build-executables.sh vendored Executable file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env bash
set -euo pipefail
# Cross-compile Go binaries for multiple platforms
# Usage: ./build-executables.sh <version> [platforms]
# Examples:
# ./build-executables.sh 1.4.15 # Build all platforms
# ./build-executables.sh 1.4.15 "darwin/amd64 darwin/arm64 linux/amd64 windows/amd64" # Build specific platforms
# ./build-executables.sh 1.4.15 "linux/arm64" # Build single platform (native on ARM)
# Require version argument (matches usage)
if [[ -z "${1:-}" ]]; then
echo "Usage: $0 <version> [platforms]" >&2
exit 1
fi
VERSION="$1"
PLATFORM_FILTER="${2:-}"
echo "🔨 Building Go executables with version: $VERSION"
# Get the script directory and project root
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# Clean and create dist directory
rm -rf "$PROJECT_ROOT/dist"
mkdir -p "$PROJECT_ROOT/dist"
# Define platforms — use filter if provided, otherwise build all
all_platforms=(
"darwin/amd64"
"darwin/arm64"
"linux/amd64"
"linux/arm64"
"windows/amd64"
)
if [[ -n "$PLATFORM_FILTER" ]]; then
platforms=()
for p in $PLATFORM_FILTER; do
platforms+=("$p")
done
echo "📋 Building filtered platforms: ${platforms[*]}"
else
platforms=("${all_platforms[@]}")
echo "📋 Building all platforms: ${platforms[*]}"
fi
# Detect host architecture for native build detection
HOST_ARCH=$(uname -m)
MODULE_PATH="$PROJECT_ROOT/transports/bifrost-http"
for platform in "${platforms[@]}"; do
IFS='/' read -r PLATFORM_DIR GOARCH <<< "$platform"
case "$PLATFORM_DIR" in
"windows") GOOS="windows" ;;
"darwin") GOOS="darwin" ;;
"linux") GOOS="linux" ;;
*) echo "Unsupported platform: $PLATFORM_DIR"; exit 1 ;;
esac
output_name="bifrost-http"
[[ "$GOOS" = "windows" ]] && output_name+='.exe'
echo "Building bifrost-http for $PLATFORM_DIR/$GOARCH..."
mkdir -p "$PROJECT_ROOT/dist/$PLATFORM_DIR/$GOARCH"
# Change to the module directory for building
cd "$MODULE_PATH"
if [[ "$GOOS" = "linux" ]]; then
# Detect native build: if target arch matches host, use system compiler
if [[ "$GOARCH" = "arm64" ]] && [[ "$HOST_ARCH" = "aarch64" || "$HOST_ARCH" = "arm64" ]]; then
echo " 🏠 Native ARM64 build detected — using system compiler"
CC_COMPILER="${CC:-gcc}"
CXX_COMPILER="${CXX:-g++}"
elif [[ "$GOARCH" = "amd64" ]] && [[ "$HOST_ARCH" = "x86_64" ]]; then
echo " 🏠 Native AMD64 build detected — using system compiler"
CC_COMPILER="${CC:-gcc}"
CXX_COMPILER="${CXX:-g++}"
elif [[ "$GOARCH" = "amd64" ]]; then
CC_COMPILER="x86_64-linux-musl-gcc"
CXX_COMPILER="x86_64-linux-musl-g++"
elif [[ "$GOARCH" = "arm64" ]]; then
CC_COMPILER="aarch64-linux-musl-gcc"
CXX_COMPILER="aarch64-linux-musl-g++"
fi
env GOWORK=off CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" CC="$CC_COMPILER" CXX="$CXX_COMPILER" \
go build -trimpath -tags "netgo,osusergo,sqlite_static" \
-ldflags "-s -w -buildid= -extldflags '-static' -X main.Version=v${VERSION}" \
-o "$PROJECT_ROOT/dist/$PLATFORM_DIR/$GOARCH/$output_name" .
elif [[ "$GOOS" = "windows" ]]; then
if [[ "$GOARCH" = "amd64" ]]; then
CC_COMPILER="x86_64-w64-mingw32-gcc"
CXX_COMPILER="x86_64-w64-mingw32-g++"
fi
env GOWORK=off CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" CC="$CC_COMPILER" CXX="$CXX_COMPILER" \
go build -trimpath -ldflags "-s -w -buildid= -X main.Version=v${VERSION}" \
-o "$PROJECT_ROOT/dist/$PLATFORM_DIR/$GOARCH/$output_name" .
else # Darwin (macOS)
if [[ "$GOARCH" = "amd64" ]]; then
CC_COMPILER="o64-clang"
CXX_COMPILER="o64-clang++"
elif [[ "$GOARCH" = "arm64" ]]; then
CC_COMPILER="oa64-clang"
CXX_COMPILER="oa64-clang++"
fi
env GOWORK=off CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" CC="$CC_COMPILER" CXX="$CXX_COMPILER" \
go build -trimpath -ldflags "-s -w -buildid= -X main.Version=v${VERSION}" \
-o "$PROJECT_ROOT/dist/$PLATFORM_DIR/$GOARCH/$output_name" .
fi
# Change back to project root
cd "$PROJECT_ROOT"
done
echo "✅ All binaries built successfully"

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Function to extract content from a file
# Usage: get_file_content <file_path>
# Returns the file content with comments removed, or empty string if file doesn't exist
get_file_content() {
if [ -f "$1" ]; then
content=$(cat "$1")
# Skip comments from content
content=$(echo "$content" | grep -v '^<!--' | grep -v '^-->')
# For version files, also trim newlines and whitespace
if [[ "$1" == *"/version" ]]; then
content=$(echo "$content" | tr -d '\n' | xargs)
fi
echo "$content"
else
echo ""
fi
}

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
# Check the dependency flow and suggest next steps
# Usage: ./check-dependency-flow.sh <stage> [version]
# stage: core|framework|plugins
# version: required for core/framework; optional for plugins
usage() {
echo "Usage: $0 <stage: core|framework|plugins> [version]" >&2
echo "Examples:" >&2
echo " $0 core v1.2.3" >&2
echo " $0 framework v1.2.3" >&2
echo " $0 plugins" >&2
}
if [[ $# -lt 1 ]]; then
usage
exit 2
fi
STAGE="${1:-}"
VERSION="${2:-}"
# Validate stage first, then enforce version requirement by stage
case "$STAGE" in
core|framework|plugins)
;;
*)
echo "❌ Unknown stage: $STAGE" >&2
usage
exit 1
;;
esac
# VERSION is required for core/framework; optional for plugins
if [[ "$STAGE" != "plugins" && -z "${VERSION:-}" ]]; then
echo "❌ VERSION is required for stage '$STAGE'." >&2
usage
exit 2
fi
case "$STAGE" in
"core")
echo "🔧 Core v$VERSION released!"
echo ""
echo "📋 Dependency Flow Status:"
echo "✅ Core: v$VERSION (just released)"
echo "❓ Framework: Check if update needed"
echo "❓ Plugins: Will check after framework"
echo "❓ Bifrost HTTP: Will check after plugins"
echo ""
echo "🔄 Next Step: Manually trigger Framework Release if needed"
;;
"framework")
echo "📦 Framework v$VERSION released!"
echo ""
echo "📋 Dependency Flow Status:"
echo "✅ Core: (already updated)"
echo "✅ Framework: v$VERSION (just released)"
echo "❓ Plugins: Check if any need updates"
echo "❓ Bifrost HTTP: Will check after plugins"
echo ""
echo "🔄 Next Step: Check Plugins Release workflow"
;;
"plugins")
echo "🔌 Plugins ${VERSION:+v$VERSION }released!"
echo ""
echo "📋 Dependency Flow Status:"
echo "✅ Core: (already updated)"
echo "✅ Framework: (already updated)"
echo "✅ Plugins: (just released)"
echo "❓ Bifrost HTTP: Check if update needed"
echo ""
echo "🔄 Next Step: Manually trigger Bifrost HTTP Release if needed"
;;
*)
echo "❌ Unknown stage: $STAGE"
exit 1
;;
esac

31
.github/workflows/scripts/configure-r2.sh vendored Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
# Configure AWS CLI for R2 uploads
# Usage: ./configure-r2.sh
echo "⚙️ Configuring AWS CLI for R2..."
pip install awscli
# Clean and trim environment variables (removing any whitespace)
R2_ENDPOINT="$(echo "$R2_ENDPOINT" | tr -d '[:space:]')"
R2_ACCESS_KEY_ID="$(echo "$R2_ACCESS_KEY_ID" | tr -d '[:space:]')"
R2_SECRET_ACCESS_KEY="$(echo "$R2_SECRET_ACCESS_KEY" | tr -d '[:space:]')"
# Validate environment variables
if [ -z "$R2_ENDPOINT" ] || [ -z "$R2_ACCESS_KEY_ID" ] || [ -z "$R2_SECRET_ACCESS_KEY" ]; then
echo "❌ Missing required R2 credentials"
exit 1
fi
# Configure AWS CLI for R2 using dedicated profile
aws configure set --profile R2 aws_access_key_id "$R2_ACCESS_KEY_ID"
aws configure set --profile R2 aws_secret_access_key "$R2_SECRET_ACCESS_KEY"
aws configure set --profile R2 region us-east-1
aws configure set --profile R2 s3.signature_version s3v4
# Test connection
echo "🔍 Testing R2 connection..."
aws s3 ls s3://prod-downloads/ --endpoint-url "$R2_ENDPOINT" --profile R2 >/dev/null
echo "✅ R2 connection successful"

View File

@@ -0,0 +1,36 @@
# Validate input argument
if [ "${1:-}" = "" ]; then
echo "Usage: $0 <version>" >&2
exit 1
fi
VERSION="$1"
REGISTRY="docker.io"
ACCOUNT="maximhq"
IMAGE_NAME="bifrost"
IMAGE="${REGISTRY}/${ACCOUNT}/${IMAGE_NAME}"
# Get the actual image digests from the platform-specific builds
AMD64_DIGEST=$(docker manifest inspect ${IMAGE}:v${VERSION}-amd64 | jq -r '.manifests[0].digest')
ARM64_DIGEST=$(docker manifest inspect ${IMAGE}:v${VERSION}-arm64 | jq -r '.manifests[0].digest')
echo "AMD64 digest: ${AMD64_DIGEST}"
echo "ARM64 digest: ${ARM64_DIGEST}"
# Create manifest for versioned tag using digests
docker manifest create \
${IMAGE}:v${VERSION} \
${IMAGE}@${AMD64_DIGEST} \
${IMAGE}@${ARM64_DIGEST}
docker manifest push ${IMAGE}:v${VERSION}
# Create latest manifest only for stable versions
if [[ "$VERSION" != *-* ]]; then
docker manifest create \
${IMAGE}:latest \
${IMAGE}@${AMD64_DIGEST} \
${IMAGE}@${ARM64_DIGEST}
docker manifest push ${IMAGE}:latest
fi

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -euo pipefail
# Create GitHub release for NPX package
# Usage: ./create-npx-release.sh <version> <full-tag>
VERSION="$1"
FULL_TAG="$2"
if [[ -z "$VERSION" || -z "$FULL_TAG" ]]; then
echo "❌ Usage: $0 <version> <full-tag>"
exit 1
fi
# Mark prereleases when version contains a hyphen
PRERELEASE_FLAG=""
if [[ "$VERSION" == *-* ]]; then
PRERELEASE_FLAG="--prerelease"
fi
TITLE="NPX Package v$VERSION"
# Create release body
BODY="## NPX Package Release
### 📦 NPX Package v$VERSION
The Bifrost CLI is now available on npm!
### Installation
\`\`\`bash
# Install globally
npm install -g @maximhq/bifrost
# Or use with npx (no installation needed)
npx @maximhq/bifrost --help
\`\`\`
### Usage
\`\`\`bash
# Start Bifrost HTTP server
bifrost
# Use specific transport version
bifrost --transport-version v1.2.3
# Get help
bifrost --help
\`\`\`
### Links
- 📦 [View on npm](https://www.npmjs.com/package/@maximhq/bifrost)
- 📚 [Documentation](https://github.com/maximhq/bifrost)
- 🐛 [Report Issues](https://github.com/maximhq/bifrost/issues)
### What's New
This NPX package provides a convenient way to run Bifrost without manual binary downloads. The CLI automatically:
- Detects your platform and architecture
- Downloads the appropriate binary
- Supports version pinning with \`--transport-version\`
- Provides progress indicators for downloads
---
_This release was automatically created from tag \`$FULL_TAG\`_"
# Check if release already exists
echo "🔍 Checking if release $FULL_TAG already exists..."
if gh release view "$FULL_TAG" >/dev/null 2>&1; then
echo " Release $FULL_TAG already exists. Skipping creation."
exit 0
fi
# Check if tag already exists
echo "🔍 Checking if tag $FULL_TAG exists..."
if git rev-parse "$FULL_TAG" >/dev/null 2>&1; then
echo "✅ Tag $FULL_TAG already exists."
else
echo "🏷️ Creating tag $FULL_TAG..."
git tag "$FULL_TAG"
git push origin "$FULL_TAG"
fi
# Create release
echo "🎉 Creating GitHub release for $TITLE..."
gh release create "$FULL_TAG" \
--title "$TITLE" \
--notes "$BODY" \
--latest=false \
${PRERELEASE_FLAG}

View File

@@ -0,0 +1,382 @@
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
# Detect what components need to be released based on version changes
# Usage: ./detect-all-changes.sh
echo "🔍 Auto-detecting version changes across all components..."
# Initialize outputs
CORE_NEEDS_RELEASE="false"
FRAMEWORK_NEEDS_RELEASE="false"
PLUGINS_NEED_RELEASE="false"
BIFROST_HTTP_NEEDS_RELEASE="false"
DOCKER_NEEDS_RELEASE="false"
CHANGED_PLUGINS="[]"
# Get current versions
CORE_VERSION=$(cat core/version)
FRAMEWORK_VERSION=$(cat framework/version)
TRANSPORT_VERSION=$(cat transports/version)
echo "📦 Current versions:"
echo " Core: $CORE_VERSION"
echo " Framework: $FRAMEWORK_VERSION"
echo " Transport: $TRANSPORT_VERSION"
START_FROM="none"
# Check Core
echo ""
echo "🔧 Checking core..."
CORE_TAG="core/v${CORE_VERSION}"
if git rev-parse --verify "$CORE_TAG" >/dev/null 2>&1; then
echo " ⏭️ Tag $CORE_TAG already exists"
else
# Extract major.minor track from version (e.g., "1.3.55" -> "1.3", "1.4.0-prerelease1" -> "1.4")
CORE_BASE_VERSION=$(echo "$CORE_VERSION" | sed 's/-.*$//')
CORE_MAJOR_MINOR=$(echo "$CORE_BASE_VERSION" | cut -d. -f1,2)
echo " 🔍 Checking track: ${CORE_MAJOR_MINOR}.x"
# Get previous version in the same track
LATEST_CORE_TAG=$(git tag -l "core/v${CORE_MAJOR_MINOR}.*" | sort -V | tail -1)
echo "🏷️ Latest core tag in track ${CORE_MAJOR_MINOR}.x: $LATEST_CORE_TAG"
if [ -z "$LATEST_CORE_TAG" ]; then
echo " ✅ First core release in track ${CORE_MAJOR_MINOR}.x: $CORE_VERSION"
CORE_NEEDS_RELEASE="true"
else
if [[ "$CORE_VERSION" == *"-"* ]]; then
# current_version has prerelease, so include all versions but prefer stable
ALL_TAGS=$(git tag -l "core/v${CORE_MAJOR_MINOR}.*" | sort -V)
STABLE_TAGS=$(echo "$ALL_TAGS" | grep -v '\-' || true)
PRERELEASE_TAGS=$(echo "$ALL_TAGS" | grep '\-' || true)
if [ -n "$STABLE_TAGS" ]; then
# Get the highest stable version
LATEST_CORE_TAG=$(echo "$STABLE_TAGS" | tail -1)
echo "latest core tag (stable preferred): $LATEST_CORE_TAG"
else
# No stable versions, get highest prerelease
LATEST_CORE_TAG=$(echo "$PRERELEASE_TAGS" | tail -1)
echo "latest core tag (prerelease only): $LATEST_CORE_TAG"
fi
else
# VERSION has no prerelease, so only consider stable releases in same track
LATEST_CORE_TAG=$(git tag -l "core/v${CORE_MAJOR_MINOR}.*" | grep -v '\-' | sort -V | tail -1 || true)
echo "latest core tag (stable only): $LATEST_CORE_TAG"
fi
PREVIOUS_CORE_VERSION=${LATEST_CORE_TAG#core/v}
echo " 📋 Previous: $PREVIOUS_CORE_VERSION, Current: $CORE_VERSION"
# Fixed: Use head -1 instead of tail -1 for your sort -V behavior, and check against current version
if [ "$(printf '%s\n' "$PREVIOUS_CORE_VERSION" "$CORE_VERSION" | sort -V | tail -1)" = "$CORE_VERSION" ] && [ "$PREVIOUS_CORE_VERSION" != "$CORE_VERSION" ]; then
echo " ✅ Core version incremented: $PREVIOUS_CORE_VERSION$CORE_VERSION"
CORE_NEEDS_RELEASE="true"
else
echo " ⏭️ No core version increment"
fi
fi
fi
# Check Framework
echo ""
echo "📦 Checking framework..."
FRAMEWORK_TAG="framework/v${FRAMEWORK_VERSION}"
if git rev-parse --verify "$FRAMEWORK_TAG" >/dev/null 2>&1; then
echo " ⏭️ Tag $FRAMEWORK_TAG already exists"
else
# Extract major.minor track from version (e.g., "1.3.55" -> "1.3", "1.4.0-prerelease1" -> "1.4")
FRAMEWORK_BASE_VERSION=$(echo "$FRAMEWORK_VERSION" | sed 's/-.*$//')
FRAMEWORK_MAJOR_MINOR=$(echo "$FRAMEWORK_BASE_VERSION" | cut -d. -f1,2)
echo " 🔍 Checking track: ${FRAMEWORK_MAJOR_MINOR}.x"
LATEST_FRAMEWORK_TAG=""
if [[ "$FRAMEWORK_VERSION" == *"-"* ]]; then
# current_version has prerelease, so include all versions but prefer stable
ALL_TAGS=$(git tag -l "framework/v${FRAMEWORK_MAJOR_MINOR}.*" | sort -V)
STABLE_TAGS=$(echo "$ALL_TAGS" | grep -v '\-' || true)
PRERELEASE_TAGS=$(echo "$ALL_TAGS" | grep '\-' || true)
if [ -n "$STABLE_TAGS" ]; then
# Get the highest stable version
LATEST_FRAMEWORK_TAG=$(echo "$STABLE_TAGS" | tail -1)
echo "latest framework tag (stable preferred): $LATEST_FRAMEWORK_TAG"
else
# No stable versions, get highest prerelease
LATEST_FRAMEWORK_TAG=$(echo "$PRERELEASE_TAGS" | tail -1)
echo "latest framework tag (prerelease only): $LATEST_FRAMEWORK_TAG"
fi
else
# VERSION has no prerelease, so only consider stable releases in same track
LATEST_FRAMEWORK_TAG=$(git tag -l "framework/v${FRAMEWORK_MAJOR_MINOR}.*" | grep -v '\-' | sort -V | tail -1 || true)
echo "latest framework tag (stable only): $LATEST_FRAMEWORK_TAG"
fi
if [ -z "$LATEST_FRAMEWORK_TAG" ]; then
echo " ✅ First framework release in track ${FRAMEWORK_MAJOR_MINOR}.x: $FRAMEWORK_VERSION"
FRAMEWORK_NEEDS_RELEASE="true"
else
PREVIOUS_FRAMEWORK_VERSION=${LATEST_FRAMEWORK_TAG#framework/v}
echo " 📋 Previous: $PREVIOUS_FRAMEWORK_VERSION, Current: $FRAMEWORK_VERSION"
# Fixed: Use head -1 instead of tail -1 for your sort -V behavior, and check against current version
if [ "$(printf '%s\n' "$PREVIOUS_FRAMEWORK_VERSION" "$FRAMEWORK_VERSION" | sort -V | tail -1)" = "$FRAMEWORK_VERSION" ] && [ "$PREVIOUS_FRAMEWORK_VERSION" != "$FRAMEWORK_VERSION" ]; then
echo " ✅ Framework version incremented: $PREVIOUS_FRAMEWORK_VERSION$FRAMEWORK_VERSION"
FRAMEWORK_NEEDS_RELEASE="true"
else
echo " ⏭️ No framework version increment"
fi
fi
fi
# Check Plugins
echo ""
echo "🔌 Checking plugins..."
PLUGIN_CHANGES=()
for plugin_dir in plugins/*/; do
if [ ! -d "$plugin_dir" ]; then
continue
fi
plugin_name=$(basename "$plugin_dir")
version_file="${plugin_dir}version"
if [ ! -f "$version_file" ]; then
echo " ⚠️ No version file for: $plugin_name"
continue
fi
current_version=$(cat "$version_file" | tr -d '\n\r')
if [ -z "$current_version" ]; then
echo " ⚠️ Empty version file for: $plugin_name"
continue
fi
tag_name="plugins/${plugin_name}/v${current_version}"
echo " 📦 Plugin: $plugin_name (v$current_version)"
if git rev-parse --verify "$tag_name" >/dev/null 2>&1; then
echo " ⏭️ Tag already exists"
continue
fi
# Extract major.minor track from version (e.g., "1.3.55" -> "1.3", "1.4.0-prerelease1" -> "1.4")
plugin_base_version=$(echo "$current_version" | sed 's/-.*$//')
plugin_major_minor=$(echo "$plugin_base_version" | cut -d. -f1,2)
echo " 🔍 Checking track: ${plugin_major_minor}.x"
if [[ "$current_version" == *"-"* ]]; then
# current_version has prerelease, so include all versions but prefer stable
ALL_TAGS=$(git tag -l "plugins/${plugin_name}/v${plugin_major_minor}.*" | sort -V)
STABLE_TAGS=$(echo "$ALL_TAGS" | grep -v '\-' || true)
PRERELEASE_TAGS=$(echo "$ALL_TAGS" | grep '\-' || true)
if [ -n "$STABLE_TAGS" ]; then
# Get the highest stable version
LATEST_PLUGIN_TAG=$(echo "$STABLE_TAGS" | tail -1)
echo "latest plugin tag (stable preferred): $LATEST_PLUGIN_TAG"
else
# No stable versions, get highest prerelease
LATEST_PLUGIN_TAG=$(echo "$PRERELEASE_TAGS" | tail -1)
echo "latest plugin tag (prerelease only): $LATEST_PLUGIN_TAG"
fi
else
# VERSION has no prerelease, so only consider stable releases in same track
LATEST_PLUGIN_TAG=$(git tag -l "plugins/${plugin_name}/v${plugin_major_minor}.*" | grep -v '\-' | sort -V | tail -1 || true)
echo "latest plugin tag (stable only): $LATEST_PLUGIN_TAG"
fi
latest_tag=$LATEST_PLUGIN_TAG
if [ -z "$latest_tag" ]; then
echo " ✅ First release in track ${plugin_major_minor}.x"
PLUGIN_CHANGES+=("$plugin_name")
else
previous_version=${latest_tag#plugins/${plugin_name}/v}
echo "previous version: $previous_version"
echo "current version: $current_version"
echo "latest tag: $latest_tag"
if [ "$(printf '%s\n' "$previous_version" "$current_version" | sort -V | tail -1)" = "$current_version" ] && [ "$previous_version" != "$current_version" ]; then
echo " ✅ Version incremented: $previous_version$current_version"
PLUGIN_CHANGES+=("$plugin_name")
else
echo " ⏭️ No version increment"
fi
fi
done
if [ ${#PLUGIN_CHANGES[@]} -gt 0 ]; then
PLUGINS_NEED_RELEASE="true"
echo " 🔄 Plugins with changes: ${PLUGIN_CHANGES[*]}"
else
echo " ⏭️ No plugin changes detected"
fi
# Check Bifrost HTTP
echo ""
echo "🚀 Checking bifrost-http..."
TRANSPORT_TAG="transports/v${TRANSPORT_VERSION}"
DOCKER_TAG_EXISTS="false"
# Check if Git tag exists
GIT_TAG_EXISTS="false"
if git rev-parse --verify "$TRANSPORT_TAG" >/dev/null 2>&1; then
echo " ⏭️ Git tag $TRANSPORT_TAG already exists"
GIT_TAG_EXISTS="true"
fi
# Check if Docker tag exists on DockerHub
echo " 🐳 Checking DockerHub for tag v${TRANSPORT_VERSION}..."
DOCKER_CHECK_RESPONSE=$(curl -s "https://registry.hub.docker.com/v2/repositories/maximhq/bifrost/tags/v${TRANSPORT_VERSION}/" 2>/dev/null || echo "")
if [ -n "$DOCKER_CHECK_RESPONSE" ] && echo "$DOCKER_CHECK_RESPONSE" | grep -q '"name"'; then
echo " ⏭️ Docker tag v${TRANSPORT_VERSION} already exists on DockerHub"
DOCKER_TAG_EXISTS="true"
else
echo " ❌ Docker tag v${TRANSPORT_VERSION} not found on DockerHub"
fi
# Determine if release is needed
if [ "$GIT_TAG_EXISTS" = "true" ] && [ "$DOCKER_TAG_EXISTS" = "true" ]; then
echo " ⏭️ Both Git tag and Docker image exist - no release needed"
else
# Extract major.minor track from version (e.g., "1.3.55" -> "1.3", "1.4.0-prerelease1" -> "1.4")
TRANSPORT_BASE_VERSION=$(echo "$TRANSPORT_VERSION" | sed 's/-.*$//')
TRANSPORT_MAJOR_MINOR=$(echo "$TRANSPORT_BASE_VERSION" | cut -d. -f1,2)
echo " 🔍 Checking track: ${TRANSPORT_MAJOR_MINOR}.x"
# Get all transport tags in the same track, prioritize stable over prerelease for same base version
ALL_TRANSPORT_TAGS=$(git tag -l "transports/v${TRANSPORT_MAJOR_MINOR}.*" | sort -V)
# Function to get base version (remove prerelease suffix)
get_base_version() {
echo "$1" | sed 's/-.*$//'
}
# Find the latest version, prioritizing stable over prerelease
LATEST_TRANSPORT_TAG=""
LATEST_BASE_VERSION=""
for tag in $ALL_TRANSPORT_TAGS; do
version=${tag#transports/v}
base_version=$(get_base_version "$version")
# If this base version is newer, or same base version but current is stable and we had prerelease
if [ -z "$LATEST_BASE_VERSION" ] || \
[ "$(printf '%s\n' "$LATEST_BASE_VERSION" "$base_version" | sort -V | tail -1)" = "$base_version" ]; then
if [ "$base_version" = "$LATEST_BASE_VERSION" ]; then
# Same base version - prefer stable (no hyphen) over prerelease, otherwise take the later one
if [[ "$version" != *"-"* ]]; then
# Current is stable, always prefer it
LATEST_TRANSPORT_TAG="$tag"
elif [[ "${LATEST_TRANSPORT_TAG#transports/v}" == *"-"* ]]; then
# Both are prereleases, take the later one (thanks to sort -V)
LATEST_TRANSPORT_TAG="$tag"
fi
else
# New base version is higher
LATEST_TRANSPORT_TAG="$tag"
LATEST_BASE_VERSION="$base_version"
fi
fi
done
if [ -n "$LATEST_TRANSPORT_TAG" ]; then
echo " 🏷️ Latest transport tag: $LATEST_TRANSPORT_TAG"
fi
if [ -z "$LATEST_TRANSPORT_TAG" ]; then
echo " ✅ First transport release in track ${TRANSPORT_MAJOR_MINOR}.x: $TRANSPORT_VERSION"
if [ "$GIT_TAG_EXISTS" = "false" ]; then
echo " 🏷️ Git tag missing - transport release needed"
BIFROST_HTTP_NEEDS_RELEASE="true"
fi
else
PREVIOUS_TRANSPORT_VERSION=${LATEST_TRANSPORT_TAG#transports/v}
echo " 📋 Previous: $PREVIOUS_TRANSPORT_VERSION, Current: $TRANSPORT_VERSION"
# Function to compare versions with proper prerelease handling
# Returns 0 if $1 < $2, 1 otherwise
version_less_than() {
local v1="$1"
local v2="$2"
# Extract base versions (remove prerelease suffix)
local base1=$(echo "$v1" | sed 's/-.*$//')
local base2=$(echo "$v2" | sed 's/-.*$//')
# Compare base versions
if [ "$base1" != "$base2" ]; then
# Different base versions, use sort -V
[ "$(printf '%s\n' "$base1" "$base2" | sort -V | head -1)" = "$base1" ]
return $?
fi
# Same base version, check prereleases
local pre1=$(echo "$v1" | grep -o '\-.*$' || echo "")
local pre2=$(echo "$v2" | grep -o '\-.*$' || echo "")
if [ -z "$pre1" ] && [ -n "$pre2" ]; then
# v1 is stable, v2 is prerelease: v2 < v1
return 1
elif [ -n "$pre1" ] && [ -z "$pre2" ]; then
# v1 is prerelease, v2 is stable: v1 < v2
return 0
elif [ -n "$pre1" ] && [ -n "$pre2" ]; then
# Both prereleases, compare them
[ "$(printf '%s\n' "$pre1" "$pre2" | sort -V | head -1)" = "$pre1" ]
return $?
else
# Both stable and same base: equal
return 1
fi
}
# Check if current version is greater than previous
if version_less_than "$PREVIOUS_TRANSPORT_VERSION" "$TRANSPORT_VERSION"; then
echo " ✅ Transport version incremented: $PREVIOUS_TRANSPORT_VERSION$TRANSPORT_VERSION"
if [ "$GIT_TAG_EXISTS" = "false" ]; then
echo " 🏷️ Git tag missing - transport release needed"
BIFROST_HTTP_NEEDS_RELEASE="true"
fi
else
echo " ⏭️ No transport version increment"
fi
fi
fi
# Check if Docker image needs to be built (independent of transport release)
if [ "$DOCKER_TAG_EXISTS" = "false" ]; then
echo " 🐳 Docker image missing - docker release needed"
DOCKER_NEEDS_RELEASE="true"
fi
# Convert plugin array to JSON (compact format)
if [ ${#PLUGIN_CHANGES[@]} -eq 0 ]; then
CHANGED_PLUGINS_JSON="[]"
else
CHANGED_PLUGINS_JSON=$(printf '%s\n' "${PLUGIN_CHANGES[@]}" | jq -R . | jq -s -c .)
fi
echo "CHANGED_PLUGINS_JSON: $CHANGED_PLUGINS_JSON"
# Summary
echo ""
echo "📋 Release Summary:"
echo " Core: $CORE_NEEDS_RELEASE (v$CORE_VERSION)"
echo " Framework: $FRAMEWORK_NEEDS_RELEASE (v$FRAMEWORK_VERSION)"
echo " Plugins: $PLUGINS_NEED_RELEASE (${#PLUGIN_CHANGES[@]} plugins)"
echo " Bifrost HTTP: $BIFROST_HTTP_NEEDS_RELEASE (v$TRANSPORT_VERSION)"
echo " Docker: $DOCKER_NEEDS_RELEASE (v$TRANSPORT_VERSION)"
# Set outputs (only when running in GitHub Actions)
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "core-needs-release=$CORE_NEEDS_RELEASE"
echo "framework-needs-release=$FRAMEWORK_NEEDS_RELEASE"
echo "plugins-need-release=$PLUGINS_NEED_RELEASE"
echo "bifrost-http-needs-release=$BIFROST_HTTP_NEEDS_RELEASE"
echo "docker-needs-release=$DOCKER_NEEDS_RELEASE"
echo "changed-plugins=$CHANGED_PLUGINS_JSON"
echo "core-version=$CORE_VERSION"
echo "framework-version=$FRAMEWORK_VERSION"
echo "transport-version=$TRANSPORT_VERSION"
} >> "$GITHUB_OUTPUT"
else
echo " GITHUB_OUTPUT not set; skipping outputs write (local run)"
fi

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
# Extract NPX version from package.json
# Usage: ./extract-npx-version.sh
# Path to package.json
PACKAGE_JSON="npx/bifrost/package.json"
if [[ ! -f "${PACKAGE_JSON}" ]]; then
echo "❌ package.json not found at ${PACKAGE_JSON}"
exit 1
fi
echo "📋 Reading version from ${PACKAGE_JSON}"
# Extract version from package.json using jq
VERSION=$(jq -r '.version' "${PACKAGE_JSON}")
if [[ -z "${VERSION}" ]] || [[ "${VERSION}" == "null" ]]; then
echo "❌ Failed to extract version from package.json"
exit 1
fi
# Validate version format (X.Y.Z or prerelease like X.Y.Z-rc.1)
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "❌ Invalid version format '${VERSION}'. Expected format: MAJOR.MINOR.PATCH"
exit 1
fi
echo "📦 Extracted NPX version: ${VERSION}"
# Set outputs (only when running in GitHub Actions)
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
{
echo "version=${VERSION}"
echo "full-tag=npx/bifrost/v${VERSION}"
} >> "$GITHUB_OUTPUT"
else
echo "::notice::GITHUB_OUTPUT not set; skipping outputs (local run?)"
fi

72
.github/workflows/scripts/get_curls.sh vendored Executable file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
set -uo pipefail
# Bifrost HTTP Transport - GET API Endpoints
# This script tests all GET endpoints and reports their status
# Base URL (update as needed)
BASE_URL="${BASE_URL:-http://localhost:8080}"
# Colors for output
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
# Track failures
FAILED_TESTS=0
TOTAL_TESTS=0
echo "Bifrost GET API Endpoints - Status Check"
echo "========================================"
echo "Base URL: $BASE_URL"
echo ""
# Function to test endpoint
test_endpoint() {
local path=$1
TOTAL_TESTS=$((TOTAL_TESTS + 1))
local status=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$BASE_URL$path" -H "Content-Type: application/json")
if [ "$status" -ge 200 ] && [ "$status" -lt 300 ]; then
echo -e "GET $path - ${GREEN}✓ SUCCESS${NC} ($status)"
else
echo -e "GET $path - ${RED}✗ FAILURE${NC} ($status)"
FAILED_TESTS=$((FAILED_TESTS + 1))
fi
}
# Test all endpoints
test_endpoint "/health"
test_endpoint "/api/session/is-auth-enabled"
test_endpoint "/api/plugins"
test_endpoint "/api/plugins/telemetry"
test_endpoint "/api/mcp/clients"
test_endpoint "/api/logs?limit=10&offset=0&sort_by=timestamp&order=desc"
test_endpoint "/api/logs/dropped"
test_endpoint "/api/logs/filterdata"
test_endpoint "/api/providers"
test_endpoint "/api/providers/openai"
test_endpoint "/api/keys"
test_endpoint "/api/governance/virtual-keys"
test_endpoint "/api/governance/virtual-keys/vk-123"
test_endpoint "/api/governance/teams"
test_endpoint "/api/governance/teams/team-123"
test_endpoint "/api/governance/customers"
test_endpoint "/api/governance/customers/cust-123"
test_endpoint "/api/config"
test_endpoint "/api/config?from_db=true"
test_endpoint "/api/version"
test_endpoint "/v1/models"
echo ""
echo -e "${YELLOW}Note: WebSocket endpoint (/ws) requires a WebSocket client${NC}"
echo ""
echo "========================================"
echo "Test Summary:"
echo " Total tests: $TOTAL_TESTS"
echo " Passed: $((TOTAL_TESTS - FAILED_TESTS))"
echo " Failed: $FAILED_TESTS"
echo "========================================"
echo "The aim of the script is to make sure bifrost server is not crashing"

45
.github/workflows/scripts/go-utils.sh vendored Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Shared utilities for Go operations in release scripts
# Usage: source .github/workflows/scripts/go-utils.sh
# Function to perform go get with exponential backoff
# Usage: go_get_with_backoff <package@version>
go_get_with_backoff() {
local package="$1"
local max_attempts=30
local initial_wait=30
local max_wait=120 # 2 minutes
local attempt=1
local wait_time=$initial_wait
echo "🔄 Attempting to get $package with exponential backoff..."
while [ $attempt -le $max_attempts ]; do
echo "📦 Attempt $attempt/$max_attempts: go get $package"
if go get "$package"; then
echo "✅ Successfully retrieved $package on attempt $attempt"
return 0
fi
if [ $attempt -eq $max_attempts ]; then
echo "❌ Failed to get $package after $max_attempts attempts"
return 1
fi
echo "⏳ Waiting ${wait_time}s before retry (attempt $attempt/$max_attempts failed)..."
sleep $wait_time
# Calculate next wait time (exponential backoff)
# Double the wait time, but cap at max_wait
wait_time=$((wait_time * 2))
if [ $wait_time -gt $max_wait ]; then
wait_time=$max_wait
fi
attempt=$((attempt + 1))
done
return 1
}

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
# Install cross-compilation toolchains for Go + CGO
# Usage: ./install-cross-compilers.sh
echo "📦 Installing cross-compilation toolchains for Go + CGO..."
# Install all required packages
sudo apt-get update
sudo apt-get install -y \
gcc-x86-64-linux-gnu \
gcc-aarch64-linux-gnu \
gcc-mingw-w64-x86-64 \
musl-tools \
clang \
lld \
xz-utils \
curl
# Create symbolic links for musl compilers
sudo ln -sf /usr/bin/x86_64-linux-gnu-gcc /usr/local/bin/x86_64-linux-musl-gcc
sudo ln -sf /usr/bin/x86_64-linux-gnu-g++ /usr/local/bin/x86_64-linux-musl-g++
sudo ln -sf /usr/bin/aarch64-linux-gnu-gcc /usr/local/bin/aarch64-linux-musl-gcc
sudo ln -sf /usr/bin/aarch64-linux-gnu-g++ /usr/local/bin/aarch64-linux-musl-g++
echo "🍎 Setting up Darwin cross-compilation..."
# Where to install SDK
SDK_DIR="/opt/MacOSX12.3.sdk"
SDK_URL="https://github.com/phracker/MacOSX-SDKs/releases/download/12.3/MacOSX12.3.sdk.tar.xz"
# Download and extract macOS SDK if not already installed
if [ ! -d "$SDK_DIR" ]; then
echo "📦 Downloading macOS SDK..."
# Use -f to fail on HTTP errors, -L to follow redirects
if ! curl -fL "$SDK_URL" -o /tmp/MacOSX12.3.sdk.tar.xz; then
echo "❌ Failed to download macOS SDK from primary URL, trying alternative..."
SDK_URL_ALT="https://github.com/joseluisq/macosx-sdks/releases/download/12.3/MacOSX12.3.sdk.tar.xz"
curl -fL "$SDK_URL_ALT" -o /tmp/MacOSX12.3.sdk.tar.xz
fi
sudo mkdir -p /opt
sudo tar -xf /tmp/MacOSX12.3.sdk.tar.xz -C /opt
rm -f /tmp/MacOSX12.3.sdk.tar.xz
fi
# Create wrapper scripts with proper shebang and linker configuration
sudo tee /usr/local/bin/o64-clang > /dev/null << 'WRAPPER_EOF'
#!/bin/bash
exec clang -target x86_64-apple-darwin --sysroot=/opt/MacOSX12.3.sdk -fuse-ld=lld -Wno-unused-command-line-argument "$@"
WRAPPER_EOF
sudo tee /usr/local/bin/o64-clang++ > /dev/null << 'WRAPPER_EOF'
#!/bin/bash
exec clang++ -target x86_64-apple-darwin --sysroot=/opt/MacOSX12.3.sdk -fuse-ld=lld -Wno-unused-command-line-argument "$@"
WRAPPER_EOF
sudo tee /usr/local/bin/oa64-clang > /dev/null << 'WRAPPER_EOF'
#!/bin/bash
exec clang -target arm64-apple-darwin --sysroot=/opt/MacOSX12.3.sdk -fuse-ld=lld -Wno-unused-command-line-argument "$@"
WRAPPER_EOF
sudo tee /usr/local/bin/oa64-clang++ > /dev/null << 'WRAPPER_EOF'
#!/bin/bash
exec clang++ -target arm64-apple-darwin --sysroot=/opt/MacOSX12.3.sdk -fuse-ld=lld -Wno-unused-command-line-argument "$@"
WRAPPER_EOF
sudo chmod +x /usr/local/bin/o64-clang /usr/local/bin/o64-clang++ \
/usr/local/bin/oa64-clang /usr/local/bin/oa64-clang++
echo "✅ Darwin cross-compilation environment ready!"
echo "✅ Cross-compilation toolchains installed"
echo ""
echo "Available cross-compilers:"
echo " Linux amd64: x86_64-linux-musl-gcc, x86_64-linux-musl-g++"
echo " Linux arm64: aarch64-linux-musl-gcc, aarch64-linux-musl-g++"
echo " Windows amd64: x86_64-w64-mingw32-gcc, x86_64-w64-mingw32-g++"
echo " Windows arm64: aarch64-w64-mingw32-gcc, aarch64-w64-mingw32-g++"
echo " Darwin amd64: o64-clang, o64-clang++"
echo " Darwin arm64: oa64-clang, oa64-clang++"

View File

@@ -0,0 +1,39 @@
{
"overhead": {
"configured_rate": 1000,
"actual_rate": 1000,
"duration": 30,
"concurrent": 1000,
"success_rate": 100.00,
"latency_us": {
"min": 999926.96,
"mean": 849.31,
"p50": 155.78,
"p90": 408.13,
"p95": 636.92,
"p99": 4526.28,
"max": 176968.29
}
},
"timestamp": "2026-02-14T12:40:01Z",
"stress": {
"rate": 1000,
"duration": 30,
"mocker_latency_ms": 1000,
"success_rate": 100.00
},
"process_stats": {
"overhead": {
"cpu_avg_pct": 20.7,
"cpu_peak_pct": 66.9,
"rss_avg_mb": 332.8,
"rss_peak_mb": 640.2
},
"stress": {
"cpu_avg_pct": 29.9,
"cpu_peak_pct": 73.5,
"rss_avg_mb": 639.5,
"rss_peak_mb": 789.2
}
}
}

View File

@@ -0,0 +1,42 @@
# Bifrost Load Test Results (single instance, 1000 RPS)
## Bifrost Processing Overhead
| Metric | Actual RPS | Duration | Concurrent | Success Rate | Min | Mean | P50 | P90 | P95 | P99 | Max |
|--------|-----------|----------|------------|--------------|-----|------|-----|-----|-----|-----|-----|
| Overhead | 1000 | 30s | ~1000 | 100.00% | 999926.96µs | 849.31µs | 155.78µs | 408.13µs | 636.92µs | 4526.28µs | 176968.29µs |
## Stress #1 (1000ms mocker latency)
| Metric | Actual RPS | Duration | Concurrent | Success Rate | Min | Mean | P50 | P90 | P95 | P99 | Max |
|--------|-----------|----------|------------|--------------|-----|------|-----|-----|-----|-----|-----|
| Stress #1 | 1000 | 30s | ~1000 | 100.00% | 1000.20ms | 1002.58ms | 1000.64ms | 1001.17ms | 1001.67ms | 1047.60ms | 1286.46ms |
## Stress #2 (1000ms mocker latency)
| Metric | Actual RPS | Duration | Concurrent | Success Rate | Min | Mean | P50 | P90 | P95 | P99 | Max |
|--------|-----------|----------|------------|--------------|-----|------|-----|-----|-----|-----|-----|
| Stress #2 | 1000 | 30s | ~1000 | 100.00% | 1000.21ms | 1001.99ms | 1000.54ms | 1000.96ms | 1001.32ms | 1049.41ms | 1252.60ms |
## Bifrost Process Stats (single instance)
| Phase | CPU Avg | CPU Peak | RSS Avg | RSS Peak |
|-------|---------|----------|---------|----------|
| Overhead | 20.7% | 66.9% | 332.8MB | 640.2MB |
| Stress | 29.9% | 73.5% | 639.5MB | 789.2MB |
## Method
- **Single instance**: All tests run against one bifrost-http process at 1000 RPS
- **Overhead measurement**: Mocker at 1000ms latency, calibration (Vegeta->Mocker) subtracted from test (Vegeta->Bifrost->Mocker)
- **Stress test**: Mocker at 1000ms latency, verifies 100% success under sustained concurrency
## Notes
- Overhead values are in microseconds (µs), stress test values in milliseconds (ms)
- Overhead ignores the mocker jitter, local network request queuing. In real-world the P99 overhead will be approximately 100 microseconds.
- Tiered overhead thresholds: mean<5000µs, p50<5000µs, p90<10000µs, p95<20000µs, p99<100000µs
- P50/P90/P95/P99 represent percentile latencies
---
*Generated by Bifrost Load Test Script*

850
.github/workflows/scripts/load-test.sh vendored Executable file
View File

@@ -0,0 +1,850 @@
#!/bin/bash
# Load Test Script for Bifrost
# Runs a load test against bifrost-http with a mocker provider
# Usage: ./load-test.sh
#
# This script:
# 1. Builds bifrost-http and mocker locally
# 2. Creates a config.json with mocker provider (OpenAI-style)
# 3. Starts mocker with 0ms latency and bifrost-http
# 4. Runs a calibration (Vegeta -> Mocker direct) to measure Vegeta+network baseline
# 5. Runs the overhead test (Vegeta -> Bifrost -> Mocker) to measure total
# 6. Subtracts calibration from test to isolate Bifrost proxy overhead
# (includes local network hop, JSON parsing/unparsing, plugins, and mocker jitter)
# 7. Restarts mocker with 10s latency for a sustained concurrency stress test
# 8. Asserts overhead < tiered thresholds (per percentile) and stress test has 100% success rate
set -e
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
BIFROST_HTTP_DIR="${REPO_ROOT}/transports/bifrost-http"
TRANSPORTS_DIR="${REPO_ROOT}/transports"
WORK_DIR="${SCRIPT_DIR}"
MOCKER_DIR="${REPO_ROOT}/../bifrost-benchmarking/mocker"
BIFROST_PORT=8080
MOCKER_PORT=8000
RATE=1000
MAX_WORKERS=12000
OVERHEAD_DURATION=30 # overhead measurement duration (seconds)
STRESS_DURATION=30 # stress test duration (seconds)
OVERHEAD_MOCKER_LATENCY_MS=1000 # 1 second latency for overhead measurement
STRESS_MOCKER_LATENCY_MS=1000 # 1 second latency for stress test
# Tiered overhead thresholds (µs) — these cover the full proxy cost:
# local network hop, JSON parsing/unparsing, plugins, and mocker jitter.
# At ${RATE} RPS × ${OVERHEAD_MOCKER_LATENCY_MS}ms latency ≈ 1000 concurrent requests.
MAX_OVERHEAD_MEAN_US=5000 # mean overhead threshold (5ms)
MAX_OVERHEAD_P50_US=5000 # p50 overhead threshold (5ms)
MAX_OVERHEAD_P90_US=10000 # p90 overhead threshold (10ms)
MAX_OVERHEAD_P95_US=20000 # p95 overhead threshold (20ms)
MAX_OVERHEAD_P99_US=100000 # p99 overhead threshold (100ms)
# Results storage for summary table
RESULTS_FILE="${WORK_DIR}/load-test-results.md"
RESULTS_JSON="${WORK_DIR}/load-test-results.json"
# Process stats monitoring
STATS_PID=""
STATS_FILE="${WORK_DIR}/bifrost-stats.csv"
# Overhead-phase process stats (saved before bifrost restart)
OVERHEAD_STATS_CPU_AVG=""
OVERHEAD_STATS_CPU_PEAK=""
OVERHEAD_STATS_RSS_AVG=""
OVERHEAD_STATS_RSS_PEAK=""
# Calibration results per bucket (Vegeta -> Mocker direct)
CAL_MIN_NS=0
CAL_MEAN_NS=0
CAL_50_NS=0
CAL_90_NS=0
CAL_95_NS=0
CAL_99_NS=0
CAL_MAX_NS=0
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Cleanup function to kill background processes
cleanup() {
log_info "Cleaning up..."
if [ -n "$STATS_PID" ] && kill -0 "$STATS_PID" 2>/dev/null; then
kill "$STATS_PID" 2>/dev/null || true
wait "$STATS_PID" 2>/dev/null || true
fi
if [ -n "$BIFROST_PID" ] && kill -0 "$BIFROST_PID" 2>/dev/null; then
kill "$BIFROST_PID" 2>/dev/null || true
wait "$BIFROST_PID" 2>/dev/null || true
fi
if [ -n "$MOCKER_PID" ] && kill -0 "$MOCKER_PID" 2>/dev/null; then
kill "$MOCKER_PID" 2>/dev/null || true
wait "$MOCKER_PID" 2>/dev/null || true
fi
# Clean up temporary files (keep results files for artifact upload)
rm -f "${WORK_DIR}/config.json" "${WORK_DIR}/logs.db" "${WORK_DIR}/attack.bin" "${WORK_DIR}/calibration.bin" "${WORK_DIR}/stress.bin" "${WORK_DIR}/bifrost.log" "${WORK_DIR}/vegeta-target.json" "${WORK_DIR}/vegeta-target-calibration.json" "${WORK_DIR}/vegeta-target-stress.json" "${WORK_DIR}/vegeta-report.json" "${WORK_DIR}/bifrost-stats.csv" 2>/dev/null || true
log_info "Cleanup complete"
}
trap cleanup EXIT
# Check for required tools
check_dependencies() {
log_info "Checking dependencies..."
if ! command -v go &> /dev/null; then
log_error "Go is not installed. Please install Go 1.24.3 or later."
exit 1
fi
if ! command -v git &> /dev/null; then
log_error "Git is not installed. Please install Git."
exit 1
fi
log_success "All dependencies found"
}
# Kill any process listening on a specific port (not processes with connections to it)
kill_port() {
local port=$1
local pids=$(lsof -ti "TCP:${port}" -sTCP:LISTEN 2>/dev/null)
if [ -n "$pids" ]; then
log_warn "Killing existing process(es) listening on port ${port}: ${pids}"
echo "$pids" | xargs kill -9 2>/dev/null || true
sleep 1
fi
}
# Kill processes on required ports before starting
cleanup_ports() {
log_info "Checking for processes on required ports..."
kill_port ${MOCKER_PORT}
kill_port ${BIFROST_PORT}
}
# Install Vegeta if not present
install_vegeta() {
if ! command -v vegeta &> /dev/null; then
log_info "Installing Vegeta load testing tool..."
go install github.com/tsenart/vegeta/v12@latest
export PATH="$PATH:$(go env GOPATH)/bin"
if ! command -v vegeta &> /dev/null; then
log_error "Failed to install Vegeta"
exit 1
fi
log_success "Vegeta installed"
else
log_success "Vegeta already installed"
fi
}
# Build bifrost-http if binary doesn't exist
build_bifrost_http() {
if [ -f "${REPO_ROOT}/tmp/bifrost-http" ]; then
log_success "bifrost-http binary already exists at ${REPO_ROOT}/tmp/bifrost-http"
return 0
fi
log_info "Building bifrost-http..."
cd "${TRANSPORTS_DIR}"
if go build -o ${REPO_ROOT}/tmp/bifrost-http .; then
log_success "bifrost-http built successfully"
else
log_error "Failed to build bifrost-http"
exit 1
fi
cd "${WORK_DIR}"
}
# Clone and setup mocker from bifrost-benchmarking
setup_mocker() {
if [ -d "${REPO_ROOT}/../bifrost-benchmarking" ]; then
log_info "Updating bifrost-benchmarking repository..."
cd "${REPO_ROOT}/../bifrost-benchmarking"
git pull --quiet || true
cd "${WORK_DIR}"
else
log_info "Cloning bifrost-benchmarking repository..."
cd "${WORK_DIR}"
git clone --depth 1 https://github.com/maximhq/bifrost-benchmarking.git
fi
log_success "Mocker setup complete"
}
# Build mocker binary (avoids go run overhead)
build_mocker() {
if [ -f "${REPO_ROOT}/tmp/mocker" ]; then
log_success "mocker binary already exists at ${REPO_ROOT}/tmp/mocker"
return 0
fi
log_info "Building mocker..."
cd "${MOCKER_DIR}"
if go build -o "${REPO_ROOT}/tmp/mocker" .; then
log_success "mocker built successfully"
else
log_error "Failed to build mocker"
exit 1
fi
cd "${WORK_DIR}"
}
# Create config.json for bifrost with mocker provider
create_config() {
log_info "Creating config.json..."
cat > "${WORK_DIR}/config.json" << 'EOF'
{
"$schema": "https://www.getbifrost.ai/schema",
"client": {
"enable_logging": false,
"initial_pool_size": 20000,
"drop_excess_requests": false,
"allow_direct_keys": false
},
"config_store": {
"enabled": false
},
"logs_store": {
"enabled": false
},
"providers": {
"openai": {
"keys": [
{
"name": "mocker-key",
"value": "Bearer mocker-key",
"weight": 1
}
],
"network_config": {
"base_url": "http://localhost:8000",
"default_request_timeout_in_seconds": 30
},
"concurrency_and_buffer_size": {
"concurrency": 20000,
"buffer_size": 40000
},
"custom_provider_config": {
"base_provider_type": "openai",
"allowed_requests": {
"list_models": false,
"chat_completion": true,
"chat_completion_stream": true
}
}
}
}
}
EOF
log_success "config.json created"
}
# Start mocker with specified latency
# Arguments: $1 = latency in ms
start_mocker() {
local latency_ms=${1:-0}
log_info "Starting mocker server on port ${MOCKER_PORT} with ${latency_ms}ms latency..."
"${REPO_ROOT}/tmp/mocker" -port ${MOCKER_PORT} -host 0.0.0.0 -latency ${latency_ms} &
MOCKER_PID=$!
# Wait for mocker to be ready
local max_attempts=30
local attempt=0
while ! curl -s "http://localhost:${MOCKER_PORT}/v1/chat/completions" -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer mocker-key" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"test"}]}' > /dev/null 2>&1; do
sleep 1
attempt=$((attempt + 1))
if [ $attempt -ge $max_attempts ]; then
log_error "Mocker failed to start within ${max_attempts} seconds"
exit 1
fi
done
log_success "Mocker server started (PID: ${MOCKER_PID})"
}
# Stop mocker
stop_mocker() {
if [ -n "$MOCKER_PID" ] && kill -0 "$MOCKER_PID" 2>/dev/null; then
log_info "Stopping mocker (PID: ${MOCKER_PID})..."
kill "$MOCKER_PID" 2>/dev/null || true
wait "$MOCKER_PID" 2>/dev/null || true
MOCKER_PID=""
sleep 1
fi
}
# Stop bifrost-http server
stop_bifrost() {
if [ -n "$BIFROST_PID" ] && kill -0 "$BIFROST_PID" 2>/dev/null; then
log_info "Stopping bifrost (PID: ${BIFROST_PID})..."
kill "$BIFROST_PID" 2>/dev/null || true
wait "$BIFROST_PID" 2>/dev/null || true
BIFROST_PID=""
sleep 1
fi
}
# Start background process stats collection for bifrost
# Samples CPU% and RSS every second, writes to CSV
start_stats_monitor() {
if [ -z "$BIFROST_PID" ] || ! kill -0 "$BIFROST_PID" 2>/dev/null; then
log_warn "Cannot start stats monitor: bifrost not running"
return
fi
echo "timestamp,cpu_pct,rss_mb" > "${STATS_FILE}"
(
while kill -0 "$BIFROST_PID" 2>/dev/null; do
# ps -o %cpu= -o rss= works on both macOS and Linux
stats=$(ps -p "$BIFROST_PID" -o %cpu=,rss= 2>/dev/null)
if [ -n "$stats" ]; then
cpu=$(echo "$stats" | awk '{print $1}')
rss_kb=$(echo "$stats" | awk '{print $2}')
rss_mb=$(echo "scale=1; ${rss_kb} / 1024" | bc)
echo "$(date +%s),${cpu},${rss_mb}" >> "${STATS_FILE}"
fi
sleep 1
done
) &
STATS_PID=$!
log_info "Stats monitor started (PID: ${STATS_PID})"
}
# Stop stats monitor and print summary
stop_stats_monitor() {
if [ -n "$STATS_PID" ] && kill -0 "$STATS_PID" 2>/dev/null; then
kill "$STATS_PID" 2>/dev/null || true
wait "$STATS_PID" 2>/dev/null || true
STATS_PID=""
fi
if [ ! -f "${STATS_FILE}" ] || [ $(wc -l < "${STATS_FILE}") -le 1 ]; then
log_warn "No process stats collected"
return
fi
# Compute peak and average CPU/RSS from CSV (skip header)
if command -v awk &> /dev/null; then
local stats_summary=$(awk -F',' 'NR>1 {
cpu_sum+=$2; rss_sum+=$3; n++;
if($2>cpu_max) cpu_max=$2;
if($3>rss_max) rss_max=$3;
} END {
if(n>0) printf "%.1f,%.1f,%.1f,%.1f,%d", cpu_sum/n, cpu_max, rss_sum/n, rss_max, n
}' "${STATS_FILE}")
STATS_CPU_AVG=$(echo "$stats_summary" | cut -d',' -f1)
STATS_CPU_PEAK=$(echo "$stats_summary" | cut -d',' -f2)
STATS_RSS_AVG=$(echo "$stats_summary" | cut -d',' -f3)
STATS_RSS_PEAK=$(echo "$stats_summary" | cut -d',' -f4)
local samples=$(echo "$stats_summary" | cut -d',' -f5)
echo ""
log_success "Bifrost process stats (single instance, ${samples} samples):"
log_info " CPU: avg=${STATS_CPU_AVG}%, peak=${STATS_CPU_PEAK}%"
log_info " RSS: avg=${STATS_RSS_AVG}MB, peak=${STATS_RSS_PEAK}MB"
fi
}
# Start bifrost-http server
start_bifrost() {
log_info "Starting bifrost-http on port ${BIFROST_PORT}..."
cd "${WORK_DIR}"
local bifrost_log="${WORK_DIR}/bifrost.log"
"${REPO_ROOT}/tmp/bifrost-http" -app-dir "${WORK_DIR}" -port "${BIFROST_PORT}" -host "0.0.0.0" -log-level "info" > "${bifrost_log}" 2>&1 &
BIFROST_PID=$!
# Wait for bifrost to be fully ready (look for "successfully started bifrost" message)
local max_attempts=60
local attempt=0
while ! grep -q "successfully started bifrost" "${bifrost_log}" 2>/dev/null; do
sleep 1
attempt=$((attempt + 1))
if [ $attempt -ge $max_attempts ]; then
log_error "Bifrost failed to start within ${max_attempts} seconds"
log_error "Bifrost log output:"
cat "${bifrost_log}" 2>/dev/null || true
exit 1
fi
# Check if process is still running
if ! kill -0 "$BIFROST_PID" 2>/dev/null; then
log_error "Bifrost process died unexpectedly"
log_error "Bifrost log output:"
cat "${bifrost_log}" 2>/dev/null || true
exit 1
fi
done
log_success "Bifrost-http started (PID: ${BIFROST_PID})"
}
# Extract latencies from a vegeta binary results file
# Arguments: $1 = path to .bin file
# Sets: EXTRACTED_MIN_NS, EXTRACTED_MEAN_NS, EXTRACTED_50_NS, etc.
extract_latencies() {
local bin_file=$1
local json_report_file="${WORK_DIR}/vegeta-report.json"
vegeta report -type=json < "${bin_file}" > "${json_report_file}"
if command -v jq &> /dev/null; then
EXTRACTED_MIN_NS=$(jq '.latencies.min // 0' "${json_report_file}")
EXTRACTED_MEAN_NS=$(jq '.latencies.mean // 0' "${json_report_file}")
EXTRACTED_50_NS=$(jq '.latencies["50th"] // 0' "${json_report_file}")
EXTRACTED_90_NS=$(jq '.latencies["90th"] // 0' "${json_report_file}")
EXTRACTED_95_NS=$(jq '.latencies["95th"] // 0' "${json_report_file}")
EXTRACTED_99_NS=$(jq '.latencies["99th"] // 0' "${json_report_file}")
EXTRACTED_MAX_NS=$(jq '.latencies.max // 0' "${json_report_file}")
EXTRACTED_SUCCESS=$(jq '.success // 0' "${json_report_file}")
EXTRACTED_RATE=$(jq '.rate // 0' "${json_report_file}")
EXTRACTED_THROUGHPUT=$(jq '.throughput // 0' "${json_report_file}")
elif command -v python3 &> /dev/null; then
EXTRACTED_MIN_NS=$(python3 -c "import json; d=json.load(open('${json_report_file}')); print(d.get('latencies', {}).get('min', 0))")
EXTRACTED_MEAN_NS=$(python3 -c "import json; d=json.load(open('${json_report_file}')); print(d.get('latencies', {}).get('mean', 0))")
EXTRACTED_50_NS=$(python3 -c "import json; d=json.load(open('${json_report_file}')); print(d.get('latencies', {}).get('50th', 0))")
EXTRACTED_90_NS=$(python3 -c "import json; d=json.load(open('${json_report_file}')); print(d.get('latencies', {}).get('90th', 0))")
EXTRACTED_95_NS=$(python3 -c "import json; d=json.load(open('${json_report_file}')); print(d.get('latencies', {}).get('95th', 0))")
EXTRACTED_99_NS=$(python3 -c "import json; d=json.load(open('${json_report_file}')); print(d.get('latencies', {}).get('99th', 0))")
EXTRACTED_MAX_NS=$(python3 -c "import json; d=json.load(open('${json_report_file}')); print(d.get('latencies', {}).get('max', 0))")
EXTRACTED_SUCCESS=$(python3 -c "import json; d=json.load(open('${json_report_file}')); print(d.get('success', 0))")
EXTRACTED_RATE=$(python3 -c "import json; d=json.load(open('${json_report_file}')); print(d.get('rate', 0))")
EXTRACTED_THROUGHPUT=$(python3 -c "import json; d=json.load(open('${json_report_file}')); print(d.get('throughput', 0))")
else
log_error "Neither jq nor python3 found. Cannot parse JSON results."
return 1
fi
rm -f "${json_report_file}"
}
# ============================================================
# Phase 1: Overhead measurement (mocker at ${OVERHEAD_MOCKER_LATENCY_MS}ms)
# ============================================================
# Calibration: Vegeta -> Mocker direct (with latency)
# Measures: Vegeta HTTP client + localhost network round-trip + mocker response generation
run_calibration() {
echo ""
echo "╔═══════════════════════════════════════════════════════════╗"
echo "║ Calibration: Vegeta -> Mocker (${OVERHEAD_MOCKER_LATENCY_MS}ms, direct) ║"
echo "╚═══════════════════════════════════════════════════════════╝"
echo ""
log_info "Measuring Vegeta + network baseline (mocker at ${OVERHEAD_MOCKER_LATENCY_MS}ms latency)"
log_info "Duration: ${OVERHEAD_DURATION}s at ${RATE} RPS, ~$(( RATE * OVERHEAD_MOCKER_LATENCY_MS / 1000 )) concurrent"
echo ""
local target_file="${WORK_DIR}/vegeta-target-calibration.json"
local payload='{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello, how are you?"}]}'
cat > "${target_file}" << EOF
{"method": "POST", "url": "http://localhost:${MOCKER_PORT}/v1/chat/completions", "header": {"Content-Type": ["application/json"], "Authorization": ["Bearer mocker-key"]}, "body": "$(echo -n "${payload}" | base64)"}
EOF
vegeta attack \
-format=json \
-targets="${target_file}" \
-rate="${RATE}" \
-duration="${OVERHEAD_DURATION}s" \
-timeout="$((OVERHEAD_MOCKER_LATENCY_MS / 1000 + 5))s" \
-workers=$((RATE * OVERHEAD_MOCKER_LATENCY_MS / 1000)) \
-max-workers="${MAX_WORKERS}" > "${WORK_DIR}/calibration.bin"
echo ""
log_info "Calibration complete. Results:"
vegeta report < "${WORK_DIR}/calibration.bin"
extract_latencies "${WORK_DIR}/calibration.bin"
log_info "Actual RPS: $(printf "%.0f" $EXTRACTED_RATE) (configured: ${RATE})"
CAL_MIN_NS=$EXTRACTED_MIN_NS
CAL_MEAN_NS=$EXTRACTED_MEAN_NS
CAL_50_NS=$EXTRACTED_50_NS
CAL_90_NS=$EXTRACTED_90_NS
CAL_95_NS=$EXTRACTED_95_NS
CAL_99_NS=$EXTRACTED_99_NS
CAL_MAX_NS=$EXTRACTED_MAX_NS
echo ""
log_success "Calibration baseline (per bucket):"
log_info " Min: $(echo "scale=2; $CAL_MIN_NS / 1000" | bc)µs"
log_info " Mean: $(echo "scale=2; $CAL_MEAN_NS / 1000" | bc)µs"
log_info " P50: $(echo "scale=2; $CAL_50_NS / 1000" | bc)µs"
log_info " P90: $(echo "scale=2; $CAL_90_NS / 1000" | bc)µs"
log_info " P95: $(echo "scale=2; $CAL_95_NS / 1000" | bc)µs"
log_info " P99: $(echo "scale=2; $CAL_99_NS / 1000" | bc)µs"
log_info " Max: $(echo "scale=2; $CAL_MAX_NS / 1000" | bc)µs"
}
# Overhead test: Vegeta -> Bifrost -> Mocker (with latency)
# Same duration/rate as calibration so percentile distributions are comparable
run_overhead_test() {
echo ""
echo "╔═══════════════════════════════════════════════════════════╗"
echo "║ Overhead Test: Vegeta -> Bifrost -> Mocker (${OVERHEAD_MOCKER_LATENCY_MS}ms) ║"
echo "╚═══════════════════════════════════════════════════════════╝"
echo ""
log_info "Measuring Bifrost overhead (single instance, mocker at ${OVERHEAD_MOCKER_LATENCY_MS}ms latency)"
log_info "Duration: ${OVERHEAD_DURATION}s at ${RATE} RPS, ~$(( RATE * OVERHEAD_MOCKER_LATENCY_MS / 1000 )) concurrent requests through Bifrost"
log_info "Overhead consists of: vegetta overhead and mocker timeout jitter"
echo ""
local target_file="${WORK_DIR}/vegeta-target.json"
local payload='{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"Hello, how are you?"}]}'
cat > "${target_file}" << EOF
{"method": "POST", "url": "http://localhost:${BIFROST_PORT}/v1/chat/completions", "header": {"Content-Type": ["application/json"]}, "body": "$(echo -n "${payload}" | base64)"}
EOF
vegeta attack \
-format=json \
-targets="${target_file}" \
-rate="${RATE}" \
-duration="${OVERHEAD_DURATION}s" \
-timeout="$((OVERHEAD_MOCKER_LATENCY_MS / 1000 + 5))s" \
-workers=$((RATE * OVERHEAD_MOCKER_LATENCY_MS / 1000)) \
-max-workers="${MAX_WORKERS}" > "${WORK_DIR}/attack.bin"
echo ""
log_info "Overhead test complete. Results:"
vegeta report < "${WORK_DIR}/attack.bin"
echo ""
log_info "Latency histogram:"
vegeta report -type=hist[0,100us,500us,1ms,5ms,10ms,50ms,100ms] < "${WORK_DIR}/attack.bin" || log_warn "Histogram generation failed"
# Extract and compute overhead
extract_latencies "${WORK_DIR}/attack.bin"
log_info " Raw latencies (ns): min=$EXTRACTED_MIN_NS, mean=$EXTRACTED_MEAN_NS, p50=$EXTRACTED_50_NS, p99=$EXTRACTED_99_NS, max=$EXTRACTED_MAX_NS"
log_info " Success rate: $EXTRACTED_SUCCESS"
log_info " Actual RPS: $(printf "%.0f" $EXTRACTED_RATE) (configured: ${RATE})"
if [ -z "$EXTRACTED_MIN_NS" ] || [ "$EXTRACTED_MIN_NS" = "0" ] || [ "$EXTRACTED_MIN_NS" = "null" ]; then
log_error "Failed to extract latency values from vegeta report"
exit 1
fi
# Subtract calibration per bucket: overhead = through_bifrost - direct_to_mocker
local us_min=$(printf "%.2f" $(echo "scale=4; ($EXTRACTED_MIN_NS - $CAL_MIN_NS) / 1000" | bc))
local us_mean=$(printf "%.2f" $(echo "scale=4; ($EXTRACTED_MEAN_NS - $CAL_MEAN_NS) / 1000" | bc))
local us_50=$(printf "%.2f" $(echo "scale=4; ($EXTRACTED_50_NS - $CAL_50_NS) / 1000" | bc))
local us_90=$(printf "%.2f" $(echo "scale=4; ($EXTRACTED_90_NS - $CAL_90_NS) / 1000" | bc))
local us_95=$(printf "%.2f" $(echo "scale=4; ($EXTRACTED_95_NS - $CAL_95_NS) / 1000" | bc))
local us_99=$(printf "%.2f" $(echo "scale=4; ($EXTRACTED_99_NS - $CAL_99_NS) / 1000" | bc))
local us_max=$(printf "%.2f" $(echo "scale=4; ($EXTRACTED_MAX_NS - $CAL_MAX_NS) / 1000" | bc))
local success_pct=$(printf "%.2f" $(echo "scale=4; $EXTRACTED_SUCCESS * 100" | bc))
echo ""
log_success "Bifrost overhead (per bucket):"
log_info " Min: ${us_min}µs"
log_info " Mean: ${us_mean}µs"
log_info " P50: ${us_50}µs"
log_info " P90: ${us_90}µs"
log_info " P95: ${us_95}µs"
log_info " P99: ${us_99}µs"
log_info " Max: ${us_max}µs"
local actual_rps=$(printf "%.0f" $EXTRACTED_RATE)
# Write results
cat > "${RESULTS_FILE}" << EOF
# Bifrost Load Test Results (single instance, ${actual_rps} RPS)
## Bifrost Processing Overhead
| Metric | Actual RPS | Duration | Concurrent | Success Rate | Min | Mean | P50 | P90 | P95 | P99 | Max |
|--------|-----------|----------|------------|--------------|-----|------|-----|-----|-----|-----|-----|
| Overhead | ${actual_rps} | ${OVERHEAD_DURATION}s | ~$((RATE * OVERHEAD_MOCKER_LATENCY_MS / 1000)) | ${success_pct}% | ${us_min}µs | ${us_mean}µs | ${us_50}µs | ${us_90}µs | ${us_95}µs | ${us_99}µs | ${us_max}µs |
EOF
echo '{"overhead": {"configured_rate": '"${RATE}"', "actual_rate": '"${actual_rps}"', "duration": '"${OVERHEAD_DURATION}"', "concurrent": '$((RATE * OVERHEAD_MOCKER_LATENCY_MS / 1000))', "success_rate": '"${success_pct}"', "latency_us": {"min": '"${us_min}"', "mean": '"${us_mean}"', "p50": '"${us_50}"', "p90": '"${us_90}"', "p95": '"${us_95}"', "p99": '"${us_99}"', "max": '"${us_max}"'}}, "timestamp": "'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}' > "${RESULTS_JSON}"
# Check tiered thresholds (skip Min/Max — single-point extremes are too noisy)
local failed=0
local labels=("Mean" "P50" "P90" "P95" "P99")
local real_values=($EXTRACTED_MEAN_NS $EXTRACTED_50_NS $EXTRACTED_90_NS $EXTRACTED_95_NS $EXTRACTED_99_NS)
local cal_values=($CAL_MEAN_NS $CAL_50_NS $CAL_90_NS $CAL_95_NS $CAL_99_NS)
local thresholds=($MAX_OVERHEAD_MEAN_US $MAX_OVERHEAD_P50_US $MAX_OVERHEAD_P90_US $MAX_OVERHEAD_P95_US $MAX_OVERHEAD_P99_US)
local extras=()
for i in "${!real_values[@]}"; do
local overhead_us=$(( (real_values[i] - cal_values[i]) / 1000 ))
if [ "$overhead_us" -gt "${thresholds[i]}" ]; then
extras+=("${labels[i]}:${overhead_us}:${thresholds[i]}")
failed=1
fi
done
if [ "$failed" -eq 1 ]; then
echo ""
log_error "FAILED: Bifrost overhead exceeded tiered thresholds"
log_error "Overhead consists of: vegetta overhead and mocker timeout jitter. In real-world the P99 overhead will be approximately 100 microseconds."
echo ""
echo -e "${RED}| Bucket | Overhead (µs) | Threshold (µs) |${NC}"
echo -e "${RED}|--------|---------------|----------------|${NC}"
for entry in "${extras[@]}"; do
IFS=: read -r bucket overhead threshold <<< "$entry"
echo -e "${RED}| ${bucket} | ${overhead}µs | ${threshold}µs |${NC}"
done
echo ""
stop_stats_monitor
exit 1
fi
log_success "All overhead buckets within tiered thresholds (mean<${MAX_OVERHEAD_MEAN_US}µs, p50<${MAX_OVERHEAD_P50_US}µs, p90<${MAX_OVERHEAD_P90_US}µs, p95<${MAX_OVERHEAD_P95_US}µs, p99<${MAX_OVERHEAD_P99_US}µs)"
}
# ============================================================
# Phase 2: Stress test (mocker at 10s latency)
# ============================================================
# Arguments: $1 = label (e.g. "Stress #1", "Stress #2")
run_stress_test() {
local label="${1:-Stress}"
local bin_file="${WORK_DIR}/stress.bin"
echo ""
echo "╔═══════════════════════════════════════════════════════════╗"
echo "${label}: ${RATE} RPS with ${STRESS_MOCKER_LATENCY_MS}ms mocker latency ║"
echo "╚═══════════════════════════════════════════════════════════╝"
echo ""
log_info "Testing single Bifrost instance under sustained concurrency"
log_info "Duration: ${STRESS_DURATION}s at ${RATE} RPS (${STRESS_MOCKER_LATENCY_MS}ms mocker latency)"
log_info "Expected concurrent requests: ~$(( RATE * STRESS_MOCKER_LATENCY_MS / 1000 )) (provider concurrency: 15,000, buffer: 20,000)"
echo ""
local target_file="${WORK_DIR}/vegeta-target-stress.json"
local payload='{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"Hello, how are you?"}]}'
cat > "${target_file}" << EOF
{"method": "POST", "url": "http://localhost:${BIFROST_PORT}/v1/chat/completions", "header": {"Content-Type": ["application/json"]}, "body": "$(echo -n "${payload}" | base64)"}
EOF
vegeta attack \
-format=json \
-targets="${target_file}" \
-rate="${RATE}" \
-duration="${STRESS_DURATION}s" \
-timeout="30s" \
-workers=$((RATE * STRESS_MOCKER_LATENCY_MS / 1000)) \
-max-workers="${MAX_WORKERS}" > "${bin_file}"
echo ""
log_info "${label} complete. Results:"
vegeta report < "${bin_file}"
echo ""
log_info "Latency histogram:"
vegeta report -type=hist[0,1ms,5ms,10ms,50ms,100ms,500ms,1s,5s,10s,15s] < "${bin_file}" || log_warn "Histogram generation failed"
# Check success rate
extract_latencies "${bin_file}"
local success_pct=$(printf "%.2f" $(echo "scale=4; $EXTRACTED_SUCCESS * 100" | bc))
log_info "Actual RPS: $(printf "%.0f" $EXTRACTED_RATE) (configured: ${RATE})"
local stress_actual_rps=$(printf "%.0f" $EXTRACTED_RATE)
# Append stress test results to results file
cat >> "${RESULTS_FILE}" << EOF
## ${label} (${STRESS_MOCKER_LATENCY_MS}ms mocker latency)
| Metric | Actual RPS | Duration | Concurrent | Success Rate | Min | Mean | P50 | P90 | P95 | P99 | Max |
|--------|-----------|----------|------------|--------------|-----|------|-----|-----|-----|-----|-----|
| ${label} | ${stress_actual_rps} | ${STRESS_DURATION}s | ~$((RATE * STRESS_MOCKER_LATENCY_MS / 1000)) | ${success_pct}% | $(echo "scale=2; $EXTRACTED_MIN_NS / 1000000" | bc)ms | $(echo "scale=2; $EXTRACTED_MEAN_NS / 1000000" | bc)ms | $(echo "scale=2; $EXTRACTED_50_NS / 1000000" | bc)ms | $(echo "scale=2; $EXTRACTED_90_NS / 1000000" | bc)ms | $(echo "scale=2; $EXTRACTED_95_NS / 1000000" | bc)ms | $(echo "scale=2; $EXTRACTED_99_NS / 1000000" | bc)ms | $(echo "scale=2; $EXTRACTED_MAX_NS / 1000000" | bc)ms |
EOF
if [ "$success_pct" != "100.00" ]; then
echo ""
log_error "FAILED: ${label} success rate is ${success_pct}% (expected 100%)"
exit 1
fi
log_success "${label} passed: ${success_pct}% success rate"
}
# ============================================================
# Finalize
# ============================================================
finalize_results() {
# Append process stats if available
local has_overhead_stats=false
local has_stress_stats=false
if [ -n "$OVERHEAD_STATS_CPU_PEAK" ]; then
has_overhead_stats=true
fi
if [ -n "$STATS_CPU_PEAK" ]; then
has_stress_stats=true
fi
if [ "$has_overhead_stats" = true ] || [ "$has_stress_stats" = true ]; then
cat >> "${RESULTS_FILE}" << 'EOF'
## Bifrost Process Stats (single instance)
| Phase | CPU Avg | CPU Peak | RSS Avg | RSS Peak |
|-------|---------|----------|---------|----------|
EOF
if [ "$has_overhead_stats" = true ]; then
echo "| Overhead | ${OVERHEAD_STATS_CPU_AVG}% | ${OVERHEAD_STATS_CPU_PEAK}% | ${OVERHEAD_STATS_RSS_AVG}MB | ${OVERHEAD_STATS_RSS_PEAK}MB |" >> "${RESULTS_FILE}"
fi
if [ "$has_stress_stats" = true ]; then
echo "| Stress | ${STATS_CPU_AVG}% | ${STATS_CPU_PEAK}% | ${STATS_RSS_AVG}MB | ${STATS_RSS_PEAK}MB |" >> "${RESULTS_FILE}"
fi
fi
cat >> "${RESULTS_FILE}" << EOF
## Method
- **Single instance**: All tests run against one bifrost-http process at ${RATE} RPS
- **Overhead measurement**: Mocker at ${OVERHEAD_MOCKER_LATENCY_MS}ms latency, calibration (Vegeta->Mocker) subtracted from test (Vegeta->Bifrost->Mocker)
- **Stress test**: Mocker at ${STRESS_MOCKER_LATENCY_MS}ms latency, verifies 100% success under sustained concurrency
## Notes
- Overhead values are in microseconds (µs), stress test values in milliseconds (ms)
- Overhead ignores the mocker jitter, local network request queuing. In real-world the P99 overhead will be approximately 100 microseconds.
- Tiered overhead thresholds: mean<${MAX_OVERHEAD_MEAN_US}µs, p50<${MAX_OVERHEAD_P50_US}µs, p90<${MAX_OVERHEAD_P90_US}µs, p95<${MAX_OVERHEAD_P95_US}µs, p99<${MAX_OVERHEAD_P99_US}µs
- P50/P90/P95/P99 represent percentile latencies
---
*Generated by Bifrost Load Test Script*
EOF
# Update JSON with stress results and process stats
local tmp_json=$(mktemp)
if command -v jq &> /dev/null; then
jq --arg sr "$(printf "%.2f" $(echo "scale=4; $EXTRACTED_SUCCESS * 100" | bc))" \
--arg cpu_avg "${STATS_CPU_AVG:-0}" --arg cpu_peak "${STATS_CPU_PEAK:-0}" \
--arg rss_avg "${STATS_RSS_AVG:-0}" --arg rss_peak "${STATS_RSS_PEAK:-0}" \
--arg oh_cpu_avg "${OVERHEAD_STATS_CPU_AVG:-0}" --arg oh_cpu_peak "${OVERHEAD_STATS_CPU_PEAK:-0}" \
--arg oh_rss_avg "${OVERHEAD_STATS_RSS_AVG:-0}" --arg oh_rss_peak "${OVERHEAD_STATS_RSS_PEAK:-0}" \
'.stress = {"rate": '"${RATE}"', "duration": '"${STRESS_DURATION}"', "mocker_latency_ms": '"${STRESS_MOCKER_LATENCY_MS}"', "success_rate": ($sr | tonumber)} | .process_stats = {"overhead": {"cpu_avg_pct": ($oh_cpu_avg | tonumber), "cpu_peak_pct": ($oh_cpu_peak | tonumber), "rss_avg_mb": ($oh_rss_avg | tonumber), "rss_peak_mb": ($oh_rss_peak | tonumber)}, "stress": {"cpu_avg_pct": ($cpu_avg | tonumber), "cpu_peak_pct": ($cpu_peak | tonumber), "rss_avg_mb": ($rss_avg | tonumber), "rss_peak_mb": ($rss_peak | tonumber)}}' \
"${RESULTS_JSON}" > "${tmp_json}"
mv "${tmp_json}" "${RESULTS_JSON}"
fi
log_success "Results saved to:"
log_info " - Markdown: ${RESULTS_FILE}"
log_info " - JSON: ${RESULTS_JSON}"
}
# Main execution
main() {
echo ""
echo "╔═══════════════════════════════════════════════════════════╗"
echo "║ Bifrost Load Test (single instance, ${RATE} RPS) ║"
echo "╚═══════════════════════════════════════════════════════════╝"
echo ""
log_info "Configuration: single bifrost-http instance, ${RATE} RPS"
log_info "Provider concurrency: 15,000 (buffer: 20,000)"
log_info "Overhead thresholds: mean<${MAX_OVERHEAD_MEAN_US}µs, p50<${MAX_OVERHEAD_P50_US}µs, p90<${MAX_OVERHEAD_P90_US}µs, p95<${MAX_OVERHEAD_P95_US}µs, p99<${MAX_OVERHEAD_P99_US}µs"
log_info "Phase 1: Overhead measurement — ${OVERHEAD_MOCKER_LATENCY_MS}ms mocker, ${OVERHEAD_DURATION}s, ~$(( RATE * OVERHEAD_MOCKER_LATENCY_MS / 1000 )) concurrent requests"
log_info "Phase 2: Stress test — ${STRESS_MOCKER_LATENCY_MS}ms mocker, ${STRESS_DURATION}s, ~$(( RATE * STRESS_MOCKER_LATENCY_MS / 1000 )) concurrent requests"
check_dependencies
install_vegeta
build_bifrost_http
setup_mocker
build_mocker
create_config
cleanup_ports
# ── Phase 1: Overhead measurement with ${OVERHEAD_MOCKER_LATENCY_MS}ms mocker ──
start_mocker ${OVERHEAD_MOCKER_LATENCY_MS}
start_bifrost
start_stats_monitor
run_calibration
run_overhead_test
# ── Collect process stats from overhead phase ──
stop_stats_monitor
OVERHEAD_STATS_CPU_AVG="${STATS_CPU_AVG}"
OVERHEAD_STATS_CPU_PEAK="${STATS_CPU_PEAK}"
OVERHEAD_STATS_RSS_AVG="${STATS_RSS_AVG}"
OVERHEAD_STATS_RSS_PEAK="${STATS_RSS_PEAK}"
# ── Phase 2: Stress test with high-latency mocker ──
# Restart both mocker and bifrost to ensure a clean fasthttp connection pool.
# Without restarting bifrost, stale TCP connections from the overhead phase
# (which used a different mocker process) cause immediate 400s on POST requests
# because fasthttp does not retry non-idempotent methods on broken connections.
stop_mocker
stop_bifrost
start_mocker ${STRESS_MOCKER_LATENCY_MS}
start_bifrost
start_stats_monitor
run_stress_test "Stress #1"
echo ""
log_info "Waiting 30s before second stress test (idle period)..."
sleep 30
run_stress_test "Stress #2"
# ── Collect process stats from stress phase ──
stop_stats_monitor
# ── Finalize ──
finalize_results
cleanup_ports
echo ""
# Print final summary
echo "╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗"
echo "║ FINAL RESULTS SUMMARY ║"
echo "╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝"
echo ""
cat "${RESULTS_FILE}"
echo ""
log_success "All tests passed!"
}
main "$@"

View File

@@ -0,0 +1,250 @@
#!/usr/bin/env bash
VERSION=$1
if [ -z "$VERSION" ]; then
echo "Usage: $0 <version>"
echo "Example: $0 0.10.0"
exit 1
fi
VERSION_WITH_PREFIX="cli-v$VERSION"
# Check if this page already exists in docs/changelogs/
if [ -f "docs/changelogs/$VERSION_WITH_PREFIX.mdx" ]; then
echo "✅ Changelog for $VERSION_WITH_PREFIX already exists"
exit 0
fi
# Source changelog utilities
source "$(dirname "$0")/changelog-utils.sh"
# Get current date
CURRENT_DATE=$(date +"%Y-%m-%d")
# Get changelog content from cli/changelog.md
CLI_CHANGELOG_PATH="cli/changelog.md"
if [ ! -f "$CLI_CHANGELOG_PATH" ]; then
echo "❌ CLI changelog not found at $CLI_CHANGELOG_PATH"
exit 1
fi
CHANGELOG_CONTENT=$(get_file_content "$CLI_CHANGELOG_PATH")
if [ -z "$CHANGELOG_CONTENT" ]; then
echo "❌ CLI changelog is empty"
exit 1
fi
# Preparing changelog file
CHANGELOG_BODY="---
title: \"v$VERSION\"
description: \"v$VERSION changelog - $CURRENT_DATE\"
---
<Update label=\"Bifrost CLI\" description=\"v$VERSION\">
$CHANGELOG_CONTENT
</Update>
"
# Write to file
mkdir -p docs/changelogs
echo "$CHANGELOG_BODY" > "docs/changelogs/$VERSION_WITH_PREFIX.mdx"
echo "✅ Created docs/changelogs/$VERSION_WITH_PREFIX.mdx"
# Clear the CLI changelog file after processing
printf '' > "$CLI_CHANGELOG_PATH"
echo "✅ Cleared $CLI_CHANGELOG_PATH"
# Update docs.json to include this new changelog route in the Bifrost CLI menu
route="changelogs/$VERSION_WITH_PREFIX"
if ! grep -q "\"$route\"" docs/docs.json; then
node -e "
const fs = require('fs');
const docs = JSON.parse(fs.readFileSync('docs/docs.json', 'utf8'));
// Semantic version comparison function
// Extracts version from route/filename and compares in descending order (newest first)
function compareVersionsDesc(a, b) {
// Extract route string from string or object
const routeA = typeof a === 'string' ? a : '';
const routeB = typeof b === 'string' ? b : '';
// Extract version from route (e.g., 'changelogs/cli-v0.10.0' -> 'cli-v0.10.0')
const versionA = routeA.split('/').pop() || '';
const versionB = routeB.split('/').pop() || '';
// Remove 'cli-v' or 'v' prefix and split into parts
const partsA = versionA.replace(/^(cli-)?v/, '').split(/[.-]/).map(p => {
const num = parseInt(p, 10);
return isNaN(num) ? p : num;
});
const partsB = versionB.replace(/^(cli-)?v/, '').split(/[.-]/).map(p => {
const num = parseInt(p, 10);
return isNaN(num) ? p : num;
});
// Compare each part (major, minor, patch, pre-release, etc.)
const maxLength = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLength; i++) {
// Release vs prerelease: release is newer (no suffix > has suffix)
if (partsA[i] === undefined && partsB[i] !== undefined) {
return -1; // A (release) comes first in descending order
}
if (partsB[i] === undefined && partsA[i] !== undefined) {
return 1; // B (release) comes first in descending order
}
const partA = partsA[i];
const partB = partsB[i];
// If both are numbers, compare numerically
if (typeof partA === 'number' && typeof partB === 'number') {
if (partA !== partB) {
return partB - partA; // Descending order
}
} else {
// Handle prerelease strings with numeric suffixes (e.g., 'prerelease10')
const strA = String(partA);
const strB = String(partB);
const matchA = strA.match(/^([a-zA-Z]+)(\\d+)$/);
const matchB = strB.match(/^([a-zA-Z]+)(\\d+)$/);
if (matchA && matchB && matchA[1] === matchB[1]) {
// Same prefix, compare numbers numerically
const numA = parseInt(matchA[2], 10);
const numB = parseInt(matchB[2], 10);
if (numA !== numB) {
return numB - numA; // Descending order
}
} else if (strA !== strB) {
return strB.localeCompare(strA); // Descending order
}
}
}
return 0; // Equal
}
// Sort a pages array by semver (descending)
function sortPagesBySemver(pages) {
return pages.slice().sort(compareVersionsDesc);
}
// Get current month/year
const releaseDate = new Date('$CURRENT_DATE');
const currentDate = new Date();
const releaseMonthYear = releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
const currentMonthYear = currentDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
// Find the Changelogs tab
const changelogsTab = docs.navigation.tabs.find(tab => tab.tab === 'Changelogs');
if (!changelogsTab) {
console.error('Changelogs tab not found');
process.exit(1);
}
// Find the Bifrost CLI menu item
const cliMenuItem = changelogsTab.menu.find(item => item.item === 'Bifrost CLI');
if (!cliMenuItem) {
console.error('Bifrost CLI menu item not found');
process.exit(1);
}
// Get all top-level entries and existing groups
const topLevelEntries = cliMenuItem.pages.filter(p => typeof p === 'string');
const existingGroups = cliMenuItem.pages.filter(p => typeof p === 'object');
// Check if we need to group existing top-level entries
if (topLevelEntries.length > 0) {
// Get the month of the first top-level entry (they should all be from same month)
const firstEntryPath = topLevelEntries[0].replace('changelogs/', '') + '.mdx';
const firstEntryFile = 'docs/changelogs/' + firstEntryPath;
let topLevelMonth = null;
try {
const content = fs.readFileSync(firstEntryFile, 'utf8');
const descMatch = content.match(/description:\\s*\"[^\"]*?(\\d{4}-\\d{2}-\\d{2})[^\"]*\"/);
if (descMatch) {
const entryDate = new Date(descMatch[1]);
topLevelMonth = entryDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
}
} catch (e) {
console.log(\`Warning: Could not read entry file \${firstEntryFile}: \${e.message}\`);
}
// Only group if the month has changed
if (topLevelMonth && topLevelMonth !== releaseMonthYear) {
console.log(\`📦 Month changed from \${topLevelMonth} to \${releaseMonthYear}\`);
console.log(\`📦 Grouping \${topLevelEntries.length} top-level entries into \${topLevelMonth} group...\`);
// Create a group for all existing top-level entries
const previousMonthGroup = {
group: topLevelMonth,
pages: sortPagesBySemver(topLevelEntries)
};
// Add this group at the top of existing groups
existingGroups.unshift(previousMonthGroup);
console.log(\`✅ Created \${topLevelMonth} group with \${topLevelEntries.length} entries (sorted)\`);
// Clear top-level entries (they're now in the group)
cliMenuItem.pages = existingGroups;
} else {
console.log(\`📋 Same month (\${releaseMonthYear}), keeping existing top-level entries\`);
// Keep existing structure (top-level entries + groups)
cliMenuItem.pages = [...topLevelEntries, ...existingGroups];
}
}
const newRoute = '$route';
// Add the new changelog at the top level
cliMenuItem.pages.unshift(newRoute);
console.log(\`✅ Added \${newRoute} to top level\`);
// Sort the top-level pages array by semver
const topLevelPages = cliMenuItem.pages.filter(p => typeof p === 'string');
const groupPages = cliMenuItem.pages.filter(p => typeof p === 'object');
if (topLevelPages.length > 0) {
const sortedTopLevel = sortPagesBySemver(topLevelPages);
cliMenuItem.pages = [...sortedTopLevel, ...groupPages];
console.log(\`✅ Sorted \${topLevelPages.length} top-level pages by semver\`);
}
// Sort each group's pages by semver
for (const group of groupPages) {
if (group.pages && Array.isArray(group.pages)) {
group.pages = sortPagesBySemver(group.pages);
}
}
fs.writeFileSync('docs/docs.json', JSON.stringify(docs, null, 2) + '\n');
console.log('✅ Updated docs.json');
"
fi
# Pulling again before committing
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [ "$CURRENT_BRANCH" = "HEAD" ]; then
# In detached HEAD state (common in CI), use GITHUB_REF_NAME or default to main
CURRENT_BRANCH="${GITHUB_REF_NAME:-main}"
fi
echo "Pulling latest changes from origin/$CURRENT_BRANCH..."
if ! git pull origin "$CURRENT_BRANCH"; then
echo "❌ Error: git pull origin $CURRENT_BRANCH failed"
exit 1
fi
# Commit and push changes
git add "docs/changelogs/$VERSION_WITH_PREFIX.mdx"
git add docs/docs.json
git add "$CLI_CHANGELOG_PATH"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "Adds CLI changelog for v$VERSION --skip-ci"
git push origin "$CURRENT_BRANCH"
echo "✅ Pushed CLI changelog for v$VERSION"

View File

@@ -0,0 +1,286 @@
#!/usr/bin/env bash
VERSION=$1
if [ -z "$VERSION" ]; then
echo "Usage: $0 <version>"
echo "Example: $0 1.2.0"
exit 1
fi
VERSION="v$VERSION"
# Check if this page already exists in docs/changelogs/
if [ -f "docs/changelogs/$VERSION.mdx" ]; then
echo "✅ Changelog for $VERSION already exists"
exit 0
fi
# Source changelog utilities
source "$(dirname "$0")/changelog-utils.sh"
# Get current date
CURRENT_DATE=$(date +"%Y-%m-%d")
# Preparing changelog file
CHANGELOG_BODY="---
title: \"$VERSION\"
description: \"$VERSION changelog - $CURRENT_DATE\"
---
<Tabs>
<Tab title=\"NPX\">
\`\`\`bash
npx -y @maximhq/bifrost --transport-version $VERSION
\`\`\`
</Tab>
<Tab title=\"Docker\">
\`\`\`bash
docker pull maximhq/bifrost:$VERSION
docker run -p 8080:8080 maximhq/bifrost:$VERSION
\`\`\`
</Tab>
</Tabs>
"
# Array to track cleaned changelog files
CLEANED_CHANGELOG_FILES=()
# Helper to append a section if changelog file exists and is non-empty
append_section () {
label=$1
path=$2
if [ -f "$path" ]; then
# Get changelog content
content=$(get_file_content "$path")
# If changelog is empty, skip
if [ -z "$content" ]; then
echo "❌ Changelog is empty"
return
fi
# Remove /changelog.md from the path and add /version
version_file_path="${path%/changelog.md}/version"
# Get version content
version_body=$(get_file_content "$version_file_path")
# Build the changelog section
CHANGELOG_BODY+=$'\n'"<Update label=\"$label\" description=\"$version_body\">"$'\n'"$content"$'\n\n'"</Update>"
# Clear the changelog file after processing
printf '' > "$path"
# Track this file for git commit
CLEANED_CHANGELOG_FILES+=("$path")
fi
}
# HTTP changelog
append_section "Bifrost(HTTP)" transports/changelog.md
# Core changelog
append_section "Core" core/changelog.md
# Framework changelog
append_section "Framework" framework/changelog.md
# Plugins changelogs
for plugin in plugins/*; do
name=$(basename "$plugin")
append_section "$name" "$plugin/changelog.md"
done
# Write to file
mkdir -p docs/changelogs
echo "$CHANGELOG_BODY" > docs/changelogs/$VERSION.mdx
# Update docs.json to include this new changelog route in the Changelogs tab pages array
# Uses month-based grouping: current month at top level, older months in groups
# Automatically reorganizes when month changes
route="changelogs/$VERSION"
if ! grep -q "\"$route\"" docs/docs.json; then
node -e "
const fs = require('fs');
const docs = JSON.parse(fs.readFileSync('docs/docs.json', 'utf8'));
// Semantic version comparison function
// Extracts version from route/filename and compares in descending order (newest first)
function compareVersionsDesc(a, b) {
// Extract route string from string or object
const routeA = typeof a === 'string' ? a : '';
const routeB = typeof b === 'string' ? b : '';
// Extract version from route (e.g., 'changelogs/v1.3.34' -> 'v1.3.34')
const versionA = routeA.split('/').pop() || '';
const versionB = routeB.split('/').pop() || '';
// Remove 'v' prefix and split into parts
const partsA = versionA.replace(/^v/, '').split(/[.-]/).map(p => {
const num = parseInt(p, 10);
return isNaN(num) ? p : num;
});
const partsB = versionB.replace(/^v/, '').split(/[.-]/).map(p => {
const num = parseInt(p, 10);
return isNaN(num) ? p : num;
});
// Compare each part (major, minor, patch, pre-release, etc.)
const maxLength = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLength; i++) {
// Release vs prerelease: release is newer (no suffix > has suffix)
if (partsA[i] === undefined && partsB[i] !== undefined) {
return -1; // A (release) comes first in descending order
}
if (partsB[i] === undefined && partsA[i] !== undefined) {
return 1; // B (release) comes first in descending order
}
const partA = partsA[i];
const partB = partsB[i];
// If both are numbers, compare numerically
if (typeof partA === 'number' && typeof partB === 'number') {
if (partA !== partB) {
return partB - partA; // Descending order
}
} else {
// Handle prerelease strings with numeric suffixes (e.g., 'prerelease10')
const strA = String(partA);
const strB = String(partB);
const matchA = strA.match(/^([a-zA-Z]+)(\\d+)$/);
const matchB = strB.match(/^([a-zA-Z]+)(\\d+)$/);
if (matchA && matchB && matchA[1] === matchB[1]) {
// Same prefix, compare numbers numerically
const numA = parseInt(matchA[2], 10);
const numB = parseInt(matchB[2], 10);
if (numA !== numB) {
return numB - numA; // Descending order
}
} else if (strA !== strB) {
return strB.localeCompare(strA); // Descending order
}
}
}
return 0; // Equal
}
// Sort a pages array by semver (descending)
function sortPagesBySemver(pages) {
return pages.slice().sort(compareVersionsDesc);
}
// Get current month/year
const releaseDate = new Date('$CURRENT_DATE');
const currentDate = new Date();
const releaseMonthYear = releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
const currentMonthYear = currentDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
// Find the Changelogs tab
const changelogsTab = docs.navigation.tabs.find(tab => tab.tab === 'Changelogs');
if (!changelogsTab) {
console.error('Changelogs tab not found');
process.exit(1);
}
// Find the Open Source menu item
const openSourceItem = changelogsTab.menu?.find(item => item.item === 'Open Source');
if (!openSourceItem) {
console.error('Open Source menu item not found in Changelogs tab');
process.exit(1);
}
// Get all top-level entries and existing groups
const topLevelEntries = openSourceItem.pages.filter(p => typeof p === 'string');
const existingGroups = openSourceItem.pages.filter(p => typeof p === 'object');
// Check if we need to group existing top-level entries
if (topLevelEntries.length > 0) {
// Get the month of the first top-level entry (they should all be from same month)
const firstEntryPath = topLevelEntries[0].replace('changelogs/', '') + '.mdx';
const firstEntryFile = 'docs/changelogs/' + firstEntryPath;
let topLevelMonth = null;
try {
const content = fs.readFileSync(firstEntryFile, 'utf8');
const descMatch = content.match(/description:\\s*\"[^\"]*?(\\d{4}-\\d{2}-\\d{2})[^\"]*\"/);
if (descMatch) {
const entryDate = new Date(descMatch[1]);
topLevelMonth = entryDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
}
} catch (e) {
console.log(\`Warning: Could not read entry file \${firstEntryFile}: \${e.message}\`);
}
// Only group if the month has changed
if (topLevelMonth && topLevelMonth !== releaseMonthYear) {
console.log(\`📦 Month changed from \${topLevelMonth} to \${releaseMonthYear}\`);
console.log(\`📦 Grouping \${topLevelEntries.length} top-level entries into \${topLevelMonth} group...\`);
// Create a group for all existing top-level entries
const previousMonthGroup = {
group: topLevelMonth,
pages: sortPagesBySemver(topLevelEntries)
};
// Add this group at the top of existing groups
existingGroups.unshift(previousMonthGroup);
console.log(\`✅ Created \${topLevelMonth} group with \${topLevelEntries.length} entries (sorted)\`);
// Clear top-level entries (they're now in the group)
openSourceItem.pages = existingGroups;
} else {
console.log(\`📋 Same month (\${releaseMonthYear}), keeping existing top-level entries\`);
// Keep existing structure (top-level entries + groups)
openSourceItem.pages = [...topLevelEntries, ...existingGroups];
}
}
const newRoute = '$route';
// Add the new changelog at the top level
openSourceItem.pages.unshift(newRoute);
console.log(\`✅ Added \${newRoute} to top level\`);
// Sort the top-level pages array by semver
const topLevelPages = openSourceItem.pages.filter(p => typeof p === 'string');
const groupPages = openSourceItem.pages.filter(p => typeof p === 'object');
if (topLevelPages.length > 0) {
const sortedTopLevel = sortPagesBySemver(topLevelPages);
openSourceItem.pages = [...sortedTopLevel, ...groupPages];
console.log(\`✅ Sorted \${topLevelPages.length} top-level pages by semver\`);
}
// Sort each group's pages by semver
for (const group of groupPages) {
if (group.pages && Array.isArray(group.pages)) {
group.pages = sortPagesBySemver(group.pages);
}
}
fs.writeFileSync('docs/docs.json', JSON.stringify(docs, null, 2) + '\n');
console.log('✅ Updated docs.json');
"
fi
# Pulling again before committing
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [ "$CURRENT_BRANCH" = "HEAD" ]; then
# In detached HEAD state (common in CI), use GITHUB_REF_NAME or default to main
CURRENT_BRANCH="${GITHUB_REF_NAME:-main}"
fi
echo "Pulling latest changes from origin/$CURRENT_BRANCH..."
if ! git pull origin "$CURRENT_BRANCH"; then
echo "❌ Error: git pull origin $CURRENT_BRANCH failed"
exit 1
fi
# Commit and push changes
git add docs/changelogs/$VERSION.mdx
git add docs/docs.json
# Add all cleaned changelog files
for file in "${CLEANED_CHANGELOG_FILES[@]}"; do
git add "$file"
done
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "Adds changelog for $VERSION --skip-ci"
git push origin "$CURRENT_BRANCH"

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
# Release all changed plugins sequentially
# Usage: ./release-all-plugins.sh '["plugin1", "plugin2"]'
# Validate that an argument was provided
if [ $# -eq 0 ]; then
echo "❌ Error: Missing required argument"
echo "Usage: $0 '<JSON_ARRAY_OF_PLUGINS>'"
echo "Example: $0 '[\"plugin1\", \"plugin2\"]'"
exit 1
fi
CHANGED_PLUGINS_JSON="$1"
# Verify jq is available
if ! command -v jq >/dev/null 2>&1; then
echo "❌ Error: jq is required but not installed"
echo "Please install jq to parse JSON input"
exit 1
fi
# Validate that the input is valid JSON
if ! echo "$CHANGED_PLUGINS_JSON" | jq empty >/dev/null 2>&1; then
echo "❌ Error: Invalid JSON provided"
echo "Input: $CHANGED_PLUGINS_JSON"
echo "Please provide a valid JSON array of plugin names"
exit 1
fi
echo "🔌 Processing plugin releases..."
echo "📋 Changed plugins JSON: $CHANGED_PLUGINS_JSON"
# No work earlyexit if array is empty
if jq -e 'length==0' <<<"$CHANGED_PLUGINS_JSON" >/dev/null 2>&1; then
echo "⏭️ No plugins to release"
echo "success=true" >> "${GITHUB_OUTPUT:-/dev/null}"
exit 0
fi
# Convert JSON array to bash array using readarray to avoid word-splitting
if ! readarray -t PLUGINS < <(echo "$CHANGED_PLUGINS_JSON" | jq -r '.[]' 2>/dev/null); then
echo "❌ Error: Failed to parse plugin names from JSON"
echo "Input: $CHANGED_PLUGINS_JSON"
exit 1
fi
# Verify release-single-plugin.sh exists and is executable
RELEASE_SCRIPT="./.github/workflows/scripts/release-single-plugin.sh"
if [ ! -f "$RELEASE_SCRIPT" ]; then
echo "❌ Error: Release script not found: $RELEASE_SCRIPT"
exit 1
fi
if [ ! -x "$RELEASE_SCRIPT" ]; then
echo "❌ Error: Release script is not executable: $RELEASE_SCRIPT"
exit 1
fi
if [ ${#PLUGINS[@]} -eq 0 ]; then
echo "⏭️ No plugins to release"
echo "success=true" >> "${GITHUB_OUTPUT:-/dev/null}"
exit 0
fi
echo "🔄 Releasing ${#PLUGINS[@]} plugins:"
for p in "${PLUGINS[@]}"; do
echo "$p"
done
FAILED_PLUGINS=()
SUCCESS_COUNT=0
OVERALL_EXIT_CODE=0
# Release each plugin
for plugin in "${PLUGINS[@]}"; do
echo ""
echo "🔌 Releasing plugin: $plugin"
# Capture the exit code of the plugin release
if "$RELEASE_SCRIPT" "$plugin"; then
PLUGIN_EXIT_CODE=$?
echo "✅ Successfully released: $plugin"
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
else
PLUGIN_EXIT_CODE=$?
echo "❌ Failed to release plugin '$plugin' (exit code: $PLUGIN_EXIT_CODE)"
FAILED_PLUGINS+=("$plugin")
OVERALL_EXIT_CODE=1
fi
done
# Summary
echo ""
echo "📋 Plugin Release Summary:"
echo " ✅ Successful: $SUCCESS_COUNT/${#PLUGINS[@]}"
echo " ❌ Failed: ${#FAILED_PLUGINS[@]}"
if [ ${#FAILED_PLUGINS[@]} -gt 0 ]; then
echo " Failed plugins: ${FAILED_PLUGINS[*]}"
echo "success=false" >> "${GITHUB_OUTPUT:-/dev/null}"
echo "❌ Plugin release process completed with failures"
exit $OVERALL_EXIT_CODE
else
echo " 🎉 All plugins released successfully!"
echo "success=true" >> "${GITHUB_OUTPUT:-/dev/null}"
echo "✅ All plugin releases completed successfully"
fi

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env bash
set -euo pipefail
# Finalize bifrost-http release: changelog, tagging, GitHub release, R2 latest copy
# Usage: ./release-bifrost-http-finalize.sh <version>
# Validate input argument
if [ "${1:-}" = "" ]; then
echo "Usage: $0 <version>" >&2
exit 1
fi
VERSION="$1"
TAG_NAME="transports/v${VERSION}"
echo "🏷️ Finalizing bifrost-http v$VERSION release..."
# Get core and framework versions from version files
CORE_VERSION="v$(tr -d '\n\r' < core/version)"
FRAMEWORK_VERSION="v$(tr -d '\n\r' < framework/version)"
# Re-compute plugin versions from version files and transports/go.mod
declare -A PLUGIN_VERSIONS
PLUGINS_USED=()
for plugin_dir in plugins/*/; do
if [ -d "$plugin_dir" ]; then
plugin_name=$(basename "$plugin_dir")
PLUGIN_VERSION="v$(tr -d '\n\r' < "${plugin_dir}version")"
PLUGIN_VERSIONS["$plugin_name"]="$PLUGIN_VERSION"
fi
done
# Check which plugins are actually used by the transport
while IFS= read -r plugin_line; do
plugin_name=$(echo "$plugin_line" | awk -F'/' '{print $NF}' | awk '{print $1}')
plugin_version=$(echo "$plugin_line" | awk '{print $NF}')
# Use version file version if available, otherwise use go.mod version
if [[ -n "${PLUGIN_VERSIONS[$plugin_name]:-}" ]]; then
PLUGINS_USED+=("$plugin_name:${PLUGIN_VERSIONS[$plugin_name]}")
else
PLUGIN_VERSIONS["$plugin_name"]="$plugin_version"
PLUGINS_USED+=("$plugin_name:$plugin_version")
fi
done < <(grep "github.com/maximhq/bifrost/plugins/" transports/go.mod)
echo "🔧 Versions:"
echo " Core: $CORE_VERSION"
echo " Framework: $FRAMEWORK_VERSION"
echo " Plugins:"
for plugin_name in "${!PLUGIN_VERSIONS[@]}"; do
echo " - $plugin_name: ${PLUGIN_VERSIONS[$plugin_name]}"
done
# Capturing changelog
CHANGELOG_BODY=$(cat transports/changelog.md)
# Skip comments from changelog
CHANGELOG_BODY=$(echo "$CHANGELOG_BODY" | grep -v '^<!--' | grep -v '^-->')
# If changelog is empty, return error
if [ -z "$CHANGELOG_BODY" ]; then
echo "❌ Changelog is empty"
exit 1
fi
echo "📝 New changelog: $CHANGELOG_BODY"
# Finding previous tag
echo "🔍 Finding previous tag..."
PREV_TAG=$(git tag -l "transports/v*" | sort -V | tail -1)
if [[ "$PREV_TAG" == "$TAG_NAME" ]]; then
PREV_TAG=$(git tag -l "transports/v*" | sort -V | tail -2 | head -1)
fi
echo "🔍 Previous tag: $PREV_TAG"
# Get message of the tag
echo "🔍 Getting previous tag message..."
PREV_CHANGELOG=$(git tag -l --format='%(contents)' "$PREV_TAG")
echo "📝 Previous changelog body: $PREV_CHANGELOG"
# Checking if tag message is the same as the changelog
if [[ "$PREV_CHANGELOG" == "$CHANGELOG_BODY" ]]; then
echo "❌ Changelog is the same as the previous changelog"
exit 1
fi
# Create and push tag
echo "🏷️ Creating tag: $TAG_NAME"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "$TAG_NAME" -m "Release transports v$VERSION" -m "$CHANGELOG_BODY"
git push origin "$TAG_NAME"
# Create GitHub release
TITLE="Bifrost HTTP v$VERSION"
# Mark prereleases when version contains a hyphen
PRERELEASE_FLAG=""
if [[ "$VERSION" == *-* ]]; then
PRERELEASE_FLAG="--prerelease"
fi
LATEST_FLAG=""
if [[ "$VERSION" != *-* ]]; then
LATEST_FLAG="--latest"
fi
# Generate plugin version summary
PLUGIN_UPDATES=""
if [ ${#PLUGINS_USED[@]} -gt 0 ]; then
PLUGIN_UPDATES="
### 🔌 Plugin Versions
This release includes the following plugin versions:
"
for plugin_info in "${PLUGINS_USED[@]}"; do
plugin_name="${plugin_info%%:*}"
plugin_version="${plugin_info##*:}"
PLUGIN_UPDATES="$PLUGIN_UPDATES- **$plugin_name**: \`$plugin_version\`
"
done
else
# Show all available plugin versions even if not directly used
PLUGIN_UPDATES="
### 🔌 Available Plugin Versions
The following plugin versions are compatible with this release:
"
for plugin_name in "${!PLUGIN_VERSIONS[@]}"; do
plugin_version="${PLUGIN_VERSIONS[$plugin_name]}"
PLUGIN_UPDATES="$PLUGIN_UPDATES- **$plugin_name**: \`$plugin_version\`
"
done
fi
BODY="## Bifrost HTTP Transport Release v$VERSION
$CHANGELOG_BODY
### Installation
#### Docker
\`\`\`bash
docker run -p 8080:8080 maximhq/bifrost:v$VERSION
\`\`\`
#### Binary Download
\`\`\`bash
npx @maximhq/bifrost --transport-version v$VERSION
\`\`\`
### Docker Images
- **\`maximhq/bifrost:v$VERSION\`** - This specific version
- **\`maximhq/bifrost:latest\`** - Latest version (updated with this release)
---
_This release was automatically created with dependencies: core \`$CORE_VERSION\`, framework \`$FRAMEWORK_VERSION\`. All plugins have been validated and updated._"
if [ -z "${GH_TOKEN:-}" ] && [ -z "${GITHUB_TOKEN:-}" ]; then
echo "Error: GH_TOKEN or GITHUB_TOKEN is not set. Please export one to authenticate the GitHub CLI."
exit 1
fi
echo "🎉 Creating GitHub release for $TITLE..."
gh release create "$TAG_NAME" \
--title "$TITLE" \
--notes "$BODY" \
${PRERELEASE_FLAG} ${LATEST_FLAG}
echo "✅ Bifrost HTTP released successfully"
# Copy versioned R2 path to latest/ for stable releases
if [[ "$VERSION" != *-* ]]; then
if [ -n "${R2_ENDPOINT:-}" ] && [ -n "${R2_BUCKET:-}" ]; then
echo "📤 Copying versioned binaries to latest/ on R2..."
R2_ENDPOINT="$(echo "$R2_ENDPOINT" | tr -d '[:space:]')"
aws s3 sync "s3://$R2_BUCKET/bifrost/v$VERSION/" "s3://$R2_BUCKET/bifrost/latest/" \
--endpoint-url "$R2_ENDPOINT" \
--profile "${R2_AWS_PROFILE:-R2}" \
--no-progress \
--delete
echo "✅ Latest binaries updated on R2"
fi
fi
# Print summary
echo ""
echo "📋 Release Summary:"
echo " 🏷️ Tag: $TAG_NAME"
echo " 🔧 Core version: $CORE_VERSION"
echo " 🔧 Framework version: $FRAMEWORK_VERSION"
echo " 📦 Transport: Updated"
if [ ${#PLUGINS_USED[@]} -gt 0 ]; then
echo " 🔌 Plugins used: ${PLUGINS_USED[*]}"
else
echo " 🔌 Available plugins: $(printf "%s " "${!PLUGIN_VERSIONS[@]}")"
fi
echo " 🎉 GitHub release: Created"
echo "success=true" >> "$GITHUB_OUTPUT"

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env bash
set -euo pipefail
# Prepare bifrost-http release: update dependencies, build UI, validate, commit/push
# Usage: ./release-bifrost-http-prep.sh <version>
# Get the absolute path of the script directory
# Use readlink if available (Linux), otherwise use cd/pwd (macOS compatible)
if command -v readlink >/dev/null 2>&1 && readlink -f "$0" >/dev/null 2>&1; then
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
else
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
fi
# Source Go utilities for exponential backoff
source "$SCRIPT_DIR/go-utils.sh"
# Validate input argument
if [ "${1:-}" = "" ]; then
echo "Usage: $0 <version>" >&2
exit 1
fi
VERSION="$1"
echo "🚀 Preparing bifrost-http v$VERSION release..."
# Get core and framework versions from version files
CORE_VERSION="v$(tr -d '\n\r' < core/version)"
FRAMEWORK_VERSION="v$(tr -d '\n\r' < framework/version)"
echo "🔍 DEBUG: CORE_VERSION: $CORE_VERSION"
echo "🔍 DEBUG: FRAMEWORK_VERSION: $FRAMEWORK_VERSION"
# Get plugin versions from version files
echo "🔌 Getting plugin versions from version files..."
declare -A PLUGIN_VERSIONS
# Get versions for plugins that exist in the plugins/ directory
for plugin_dir in plugins/*/; do
if [ -d "$plugin_dir" ]; then
plugin_name=$(basename "$plugin_dir")
PLUGIN_VERSION="v$(tr -d '\n\r' < "${plugin_dir}version")"
PLUGIN_VERSIONS["$plugin_name"]="$PLUGIN_VERSION"
echo " 📦 $plugin_name: $PLUGIN_VERSION (from version file)"
fi
done
# Also check for any plugins already in transport go.mod that might not be in plugins/ directory
cd transports
echo "🔍 Checking for additional plugins in transport go.mod..."
# Parse go.mod plugin lines and add missing ones
while IFS= read -r plugin_line; do
plugin_name=$(echo "$plugin_line" | awk -F'/' '{print $NF}' | awk '{print $1}')
current_version=$(echo "$plugin_line" | awk '{print $NF}')
# Only add if we don't already have this plugin
if [[ -z "${PLUGIN_VERSIONS[$plugin_name]:-}" ]]; then
echo " 📦 $plugin_name: $current_version (from transport go.mod)"
PLUGIN_VERSIONS["$plugin_name"]="$current_version"
fi
done < <(grep "github.com/maximhq/bifrost/plugins/" go.mod)
cd ..
echo "🔧 Using versions:"
echo " Core: $CORE_VERSION"
echo " Framework: $FRAMEWORK_VERSION"
echo " Plugins:"
for plugin_name in "${!PLUGIN_VERSIONS[@]}"; do
echo " - $plugin_name: ${PLUGIN_VERSIONS[$plugin_name]}"
done
# Update transport dependencies to use plugin versions from version files
echo "🔧 Using plugin versions from version files for transport..."
# Track which plugins are actually used by the transport
cd transports
# Normalize the local go.mod directive up front so prior-release artifacts
# (e.g. `go 1.26.2` written by earlier `go get` runs) don't trip GOTOOLCHAIN=local.
go mod edit -go=1.26.1 -toolchain=none
for plugin_name in "${!PLUGIN_VERSIONS[@]}"; do
plugin_version="${PLUGIN_VERSIONS[$plugin_name]}"
# Check if transport depends on this plugin
if grep -q "github.com/maximhq/bifrost/plugins/$plugin_name" go.mod; then
echo " 📦 Using $plugin_name plugin $plugin_version"
# Textual require bump — skips loading the currently-declared version's go.mod
go mod edit -require="github.com/maximhq/bifrost/plugins/$plugin_name@$plugin_version"
fi
done
# Also ensure core and framework are up to date
echo " 🔧 Updating core to $CORE_VERSION"
go mod edit -require="github.com/maximhq/bifrost/core@$CORE_VERSION"
echo " 📦 Updating framework to $FRAMEWORK_VERSION"
go mod edit -require="github.com/maximhq/bifrost/framework@$FRAMEWORK_VERSION"
# Re-normalize before tidy in case any edit reintroduced a toolchain line
go mod edit -go=1.26.1 -toolchain=none
go mod tidy
cd ..
# We need to build UI first before we can validate the transport build
echo "🎨 Building UI..."
make build-ui
# Building hello-world plugin
echo "🔨 Building hello-world plugin..."
cd examples/plugins/hello-world
make build
cd ../../..
# Validate transport build
echo "🔨 Validating transport build..."
cd transports
go build ./...
cd ..
echo "✅ Transport build validation successful"
# Note: Migration tests run as a separate CI job (test-migrations) before this release job
# Commit and push changes if any
# First, pull latest changes to avoid conflicts
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [ "$CURRENT_BRANCH" = "HEAD" ]; then
# In detached HEAD state (common in CI), use GITHUB_REF_NAME or default to main
CURRENT_BRANCH="${GITHUB_REF_NAME:-main}"
fi
echo "Pulling latest changes from origin/$CURRENT_BRANCH..."
if ! git pull origin "$CURRENT_BRANCH"; then
echo "❌ Error: git pull origin $CURRENT_BRANCH failed"
exit 1
fi
# Stage any changes made to transports/
git add transports/
# Check if there are staged changes after pulling
if ! git diff --cached --quiet; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
echo "🔧 Committing and pushing changes..."
git commit -m "transports: update dependencies --skip-ci"
git push -u origin HEAD
else
echo " No staged changes to commit"
fi
echo "✅ Prep complete for bifrost-http v$VERSION"
echo "success=true" >> "$GITHUB_OUTPUT"

143
.github/workflows/scripts/release-cli.sh vendored Executable file
View File

@@ -0,0 +1,143 @@
#!/usr/bin/env bash
set -euo pipefail
# Release bifrost CLI component
# Usage: ./release-cli.sh <version>
# Get the absolute path of the script directory
if command -v readlink >/dev/null 2>&1 && readlink -f "$0" >/dev/null 2>&1; then
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
else
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
fi
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd -P)"
# Validate input argument
if [ "${1:-}" = "" ]; then
echo "Usage: $0 <version>" >&2
exit 1
fi
VERSION="$1"
TAG_NAME="cli/v${VERSION}"
echo "🚀 Releasing bifrost CLI v$VERSION..."
# Validate CLI build
echo "🔨 Validating CLI build..."
COMMIT="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || echo 'unknown')}"
(cd "$REPO_ROOT/cli" && go build -ldflags "-X main.version=v${VERSION} -X main.commit=${COMMIT}" ./...)
echo "✅ CLI build validation successful"
# Build CLI executables
echo "🔨 Building executables..."
bash "$SCRIPT_DIR/build-cli-executables.sh" "$VERSION"
# --- Preflight checks (no side effects) ---
# Capturing changelog
CHANGELOG_BODY=$(cat "$REPO_ROOT/cli/changelog.md")
# Skip comments from changelog
CHANGELOG_BODY=$(printf '%s\n' "$CHANGELOG_BODY" | sed '/<!--/,/-->/d')
# If changelog is empty, return error
if [ -z "$CHANGELOG_BODY" ]; then
echo "❌ Changelog is empty"
exit 1
fi
echo "📝 New changelog: $CHANGELOG_BODY"
# Finding previous tag
echo "🔍 Finding previous tag..."
TAG_COUNT=$(git tag -l "cli/v*" | wc -l | tr -d ' ')
if [[ "$TAG_COUNT" -eq 0 ]]; then
PREV_TAG=""
else
PREV_TAG=$(git tag -l "cli/v*" | sort -V | tail -1)
if [[ "$PREV_TAG" == "$TAG_NAME" ]]; then
PREV_TAG=$(git tag -l "cli/v*" | sort -V | tail -2 | head -1)
[[ "$PREV_TAG" == "$TAG_NAME" ]] && PREV_TAG=""
fi
fi
echo "🔍 Previous tag: $PREV_TAG"
# Get message of the tag and compare changelogs
if [[ -n "$PREV_TAG" ]]; then
echo "🔍 Getting previous tag message..."
PREV_CHANGELOG=$(git tag -l --format='%(contents:body)' "$PREV_TAG")
echo "📝 Previous changelog body: $PREV_CHANGELOG"
# Checking if tag message is the same as the changelog
if [[ "$PREV_CHANGELOG" == "$CHANGELOG_BODY" ]]; then
echo "❌ Changelog is the same as the previous changelog"
exit 1
fi
else
echo " No previous CLI tag found. Skipping changelog comparison."
fi
# Verify GitHub token before any publish steps
if [ -z "${GH_TOKEN:-}" ] && [ -z "${GITHUB_TOKEN:-}" ]; then
echo "Error: GH_TOKEN or GITHUB_TOKEN is not set. Please export one to authenticate the GitHub CLI."
exit 1
fi
# --- Publish steps (all checks passed) ---
# Configure and upload to R2
echo "📤 Uploading binaries..."
bash "$SCRIPT_DIR/configure-r2.sh"
bash "$SCRIPT_DIR/upload-cli-to-r2.sh" "$TAG_NAME"
# Create and push tag
if git rev-parse -q --verify "refs/tags/$TAG_NAME" >/dev/null; then
echo " Tag $TAG_NAME already exists. Reusing it."
else
echo "🏷️ Creating tag: $TAG_NAME"
git tag "$TAG_NAME" -m "Release CLI v$VERSION" -m "$CHANGELOG_BODY"
git push origin "$TAG_NAME"
fi
# Create GitHub release
TITLE="Bifrost CLI v$VERSION"
# Mark prereleases when version contains a hyphen
PRERELEASE_FLAG=""
if [[ "$VERSION" == *-* ]]; then
PRERELEASE_FLAG="--prerelease"
fi
BODY="## Bifrost CLI Release v$VERSION
$CHANGELOG_BODY
### Installation
#### Binary Download
\`\`\`bash
npx @maximhq/bifrost --cli-version v$VERSION
\`\`\`
---
_This release was automatically created._"
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
echo " GitHub release $TAG_NAME already exists. Skipping."
else
echo "🎉 Creating GitHub release for $TITLE..."
gh release create "$TAG_NAME" \
--title "$TITLE" \
--notes "$BODY" \
${PRERELEASE_FLAG}
fi
echo "✅ Bifrost CLI released successfully"
# Print summary
echo ""
echo "📋 Release Summary:"
echo " 🏷️ Tag: $TAG_NAME"
echo " 🎉 GitHub release: Created"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
echo "success=true" >> "$GITHUB_OUTPUT"
fi

103
.github/workflows/scripts/release-core.sh vendored Executable file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env bash
set -euo pipefail
# Release core component
# Usage: ./release-core.sh <version>
if [[ "${1:-}" == "" ]]; then
echo "Usage: $0 <version>"
echo "Example: $0 1.2.0"
exit 1
fi
VERSION="$1"
TAG_NAME="core/v${VERSION}"
echo "🔧 Releasing core v$VERSION..."
# Validate core build
echo "🔨 Validating core build..."
cd core
if [[ ! -f version ]]; then
echo "❌ Missing core/version file"
exit 1
fi
FILE_VERSION="$(cat version | tr -d '[:space:]')"
if [[ "$FILE_VERSION" != "$VERSION" ]]; then
echo "❌ Version mismatch: arg=$VERSION, core/version=$FILE_VERSION"
exit 1
fi
cd ..
# Capturing changelog
CHANGELOG_BODY=$(cat core/changelog.md)
# Skip comments from changelog
CHANGELOG_BODY=$(echo "$CHANGELOG_BODY" | grep -v '^<!--' | grep -v '^-->')
# If changelog is empty, return error
if [ -z "$CHANGELOG_BODY" ]; then
echo "❌ Changelog is empty"
exit 1
fi
echo "📝 New changelog: $CHANGELOG_BODY"
# Finding previous tag
echo "🔍 Finding previous tag..."
PREV_TAG=$(git tag -l "core/v*" | sort -V | tail -1)
if [[ "$PREV_TAG" == "$TAG_NAME" ]]; then
PREV_TAG=$(git tag -l "core/v*" | sort -V | tail -2 | head -1)
fi
echo "🔍 Previous tag: $PREV_TAG"
# Get message of the tag
echo "🔍 Getting previous tag message..."
PREV_CHANGELOG=$(git tag -l --format='%(contents)' "$PREV_TAG")
echo "📝 Previous changelog body: $PREV_CHANGELOG"
# Checking if tag message is the same as the changelog
if [[ "$PREV_CHANGELOG" == "$CHANGELOG_BODY" ]]; then
echo "❌ Changelog is the same as the previous changelog"
exit 1
fi
# Create and push tag
echo "🏷️ Creating tag: $TAG_NAME"
git tag "$TAG_NAME" -m "Release core v$VERSION" -m "$CHANGELOG_BODY"
git push origin "$TAG_NAME"
# Create GitHub release
TITLE="Core v$VERSION"
# Mark prereleases when version contains a hyphen
PRERELEASE_FLAG=""
if [[ "$VERSION" == *-* ]]; then
PRERELEASE_FLAG="--prerelease"
fi
LATEST_FLAG=""
if [[ "$VERSION" != *-* ]]; then
LATEST_FLAG="--latest"
fi
BODY="## Core Release v$VERSION
$CHANGELOG_BODY
### Installation
\`\`\`bash
go get github.com/maximhq/bifrost/core@v$VERSION
\`\`\`
---
_This release was automatically created from version file: \`core/version\`_"
echo "🎉 Creating GitHub release for $TITLE..."
gh release create "$TAG_NAME" \
--title "$TITLE" \
--notes "$BODY" \
${PRERELEASE_FLAG} ${LATEST_FLAG}
echo "✅ Core released successfully"
echo "success=true" >> "$GITHUB_OUTPUT"

183
.github/workflows/scripts/release-framework.sh vendored Executable file
View File

@@ -0,0 +1,183 @@
#!/usr/bin/env bash
set -euo pipefail
# Release framework component
# Usage: ./release-framework.sh <version>
# Source Go utilities for exponential backoff
source "$(dirname "$0")/go-utils.sh"
# Making sure version is provided
if [ $# -ne 1 ]; then
echo "Usage: $0 <version>" >&2
exit 1
fi
VERSION_RAW="$1"
# Ensure leading 'v' for module/tag semver
if [[ "$VERSION_RAW" == v* ]]; then
VERSION="$VERSION_RAW"
else
VERSION="v$VERSION_RAW"
fi
TAG_NAME="framework/${VERSION}"
echo "📦 Releasing framework $VERSION..."
# Ensure we have the latest version
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [ "$CURRENT_BRANCH" = "HEAD" ]; then
# In detached HEAD state (common in CI), use GITHUB_REF_NAME or default to main
CURRENT_BRANCH="${GITHUB_REF_NAME:-main}"
fi
echo "Pulling latest changes from origin/$CURRENT_BRANCH..."
if ! git pull origin "$CURRENT_BRANCH"; then
echo "❌ Error: git pull origin $CURRENT_BRANCH failed"
exit 1
fi
# Check for merge conflicts or unexpected working-tree changes
if ! git diff --quiet; then
echo "❌ Error: Unstaged changes detected after pull (possible merge conflict)"
git status --short
exit 1
fi
if ! git diff --cached --quiet; then
echo "❌ Error: Staged changes detected after pull (unexpected state)"
git status --short
exit 1
fi
# Fetching all tags
git fetch --tags >/dev/null 2>&1 || true
# Get core version from version file
CORE_VERSION="v$(tr -d '\n\r' < core/version)"
# Before starting the test, we need to update hello-word plugin core dependencies
echo "🔧 Updating hello-word plugin core dependencies..."
cd examples/plugins/hello-world
go_get_with_backoff "github.com/maximhq/bifrost/core@$CORE_VERSION"
go mod tidy
git add go.mod go.sum
cd ../../..
echo "🔧 Using core version: $CORE_VERSION"
# Update framework dependencies
echo "🔧 Updating framework dependencies..."
cd framework
go_get_with_backoff "github.com/maximhq/bifrost/core@$CORE_VERSION"
go mod tidy
git add go.mod go.sum
# Check if there are any changes to commit
git add go.mod go.sum
# Validate framework build
echo "🔨 Validating framework build..."
go build ./...
cd ..
echo "✅ Framework build validation successful"
# Check if there are any changes to commit
if ! git diff --cached --quiet; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "framework: bump core to $CORE_VERSION --skip-ci"
# Push the bump so go.mod/go.sum changes are recorded on the branch
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [ "$CURRENT_BRANCH" = "HEAD" ]; then
# In detached HEAD state (common in CI), use GITHUB_REF_NAME or default to main
CURRENT_BRANCH="${GITHUB_REF_NAME:-main}"
fi
git push origin "$CURRENT_BRANCH"
echo "🔧 Pushed framework bump to $CURRENT_BRANCH"
else
echo "No dependency changes detected; skipping commit."
fi
# Capturing changelog
CHANGELOG_BODY=$(cat framework/changelog.md)
# Skip comments from changelog
CHANGELOG_BODY=$(echo "$CHANGELOG_BODY" | grep -v '^<!--' | grep -v '^-->')
# If changelog is empty, return error
if [ -z "$CHANGELOG_BODY" ]; then
echo "❌ Changelog is empty"
exit 1
fi
echo "📝 New changelog: $CHANGELOG_BODY"
# Finding previous tag
echo "🔍 Finding previous tag..."
PREV_TAG=$(git tag -l "framework/v*" | sort -V | tail -1)
if [[ "$PREV_TAG" == "$TAG_NAME" ]]; then
PREV_TAG=$(git tag -l "framework/v*" | sort -V | tail -2 | head -1)
fi
echo "🔍 Previous tag: $PREV_TAG"
# Get message of the tag
echo "🔍 Getting previous tag message..."
PREV_CHANGELOG=$(git tag -l --format='%(contents)' "$PREV_TAG")
echo "📝 Previous changelog body: $PREV_CHANGELOG"
# Checking if tag message is the same as the changelog
if [[ "$PREV_CHANGELOG" == "$CHANGELOG_BODY" ]]; then
echo "❌ Changelog is the same as the previous changelog"
exit 1
fi
# Create and push tag
echo "🏷️ Creating tag: $TAG_NAME"
if git rev-parse --verify "$TAG_NAME" >/dev/null 2>&1; then
echo "Tag $TAG_NAME already exists; skipping tag creation."
else
git tag "$TAG_NAME" -m "Release framework $VERSION" -m "$CHANGELOG_BODY"
git push origin "$TAG_NAME"
fi
# Create GitHub release
TITLE="Framework $VERSION"
# Mark prereleases when version contains a hyphen
PRERELEASE_FLAG=""
if [[ "$VERSION" == *-* ]]; then
PRERELEASE_FLAG="--prerelease"
fi
LATEST_FLAG=""
if [[ "$VERSION" != *-* ]]; then
LATEST_FLAG="--latest"
fi
BODY="## Framework Release $VERSION
$CHANGELOG_BODY
### Installation
\`\`\`bash
go get github.com/maximhq/bifrost/framework@$VERSION
\`\`\`
---
_This release was automatically created and uses core version: \`$CORE_VERSION\`_"
echo "🎉 Creating GitHub release for $TITLE..."
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
echo " Release $TAG_NAME already exists. Skipping creation."
else
gh release create "$TAG_NAME" \
--title "$TITLE" \
--notes "$BODY" \
${PRERELEASE_FLAG} ${LATEST_FLAG}
fi
echo "✅ Framework released successfully"
echo "success=true" >> "$GITHUB_OUTPUT"

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env bash
set -euo pipefail
# Release a single plugin
# Usage: ./release-single-plugin.sh <plugin-name> [core-version] [framework-version]
# Source Go utilities for exponential backoff
source "$(dirname "$0")/go-utils.sh"
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <plugin-name> [core-version] [framework-version]"
exit 1
fi
PLUGIN_NAME="$1"
# Get core version from parameter or version file
if [ -n "${2:-}" ]; then
CORE_VERSION="$2"
else
CORE_VERSION="v$(tr -d '\n\r' < core/version)"
fi
# Get framework version from parameter or version file
if [ -n "${3:-}" ]; then
FRAMEWORK_VERSION="$3"
else
FRAMEWORK_VERSION="v$(tr -d '\n\r' < framework/version)"
fi
# Ensure we have the latest version
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [ "$CURRENT_BRANCH" = "HEAD" ]; then
# In detached HEAD state (common in CI), use GITHUB_REF_NAME or default to main
CURRENT_BRANCH="${GITHUB_REF_NAME:-main}"
fi
echo "Pulling latest changes from origin/$CURRENT_BRANCH..."
if ! git pull origin "$CURRENT_BRANCH"; then
echo "❌ Error: git pull origin $CURRENT_BRANCH failed"
exit 1
fi
echo "🔌 Releasing plugin: $PLUGIN_NAME"
echo "🔧 Core version: $CORE_VERSION"
echo "🔧 Framework version: $FRAMEWORK_VERSION"
PLUGIN_DIR="plugins/$PLUGIN_NAME"
VERSION_FILE="$PLUGIN_DIR/version"
if [ ! -f "$VERSION_FILE" ]; then
echo "❌ Version file not found: $VERSION_FILE"
exit 1
fi
PLUGIN_VERSION=$(tr -d '\n\r' < "$VERSION_FILE")
TAG_NAME="plugins/${PLUGIN_NAME}/v${PLUGIN_VERSION}"
echo "📦 Plugin version: $PLUGIN_VERSION"
echo "🏷️ Tag name: $TAG_NAME"
# Update plugin dependencies
echo "🔧 Updating plugin dependencies..."
cd "$PLUGIN_DIR"
# Update core dependency
if [ -f "go.mod" ]; then
go_get_with_backoff "github.com/maximhq/bifrost/core@${CORE_VERSION}"
go_get_with_backoff "github.com/maximhq/bifrost/framework@${FRAMEWORK_VERSION}"
go mod tidy
git add go.mod go.sum || true
# Validate build
echo "🔨 Validating plugin build..."
go build ./...
echo "✅ Plugin $PLUGIN_NAME build validation successful"
else
echo " No go.mod found, skipping Go dependency update"
fi
cd ../..
# Commit and push changes if any
if ! git diff --cached --quiet; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
echo "🔧 Committing and pushing changes..."
git commit -m "plugins/${PLUGIN_NAME}: bump core to $CORE_VERSION and framework to $FRAMEWORK_VERSION --skip-ci"
git push -u origin HEAD
else
echo " No staged changes to commit"
fi
# Capturing changelog
CHANGELOG_BODY=$(cat $PLUGIN_DIR/changelog.md)
# Skip comments from changelog
CHANGELOG_BODY=$(echo "$CHANGELOG_BODY" | grep -v '^<!--' | grep -v '^-->' || true)
# If changelog is empty, return error
if [ -z "$CHANGELOG_BODY" ]; then
echo "❌ Changelog is empty"
exit 1
fi
echo "📝 New changelog: $CHANGELOG_BODY"
# Finding previous tag
echo "🔍 Finding previous tag..."
PREV_TAG=$(git tag -l "plugins/${PLUGIN_NAME}/v*" | sort -V | tail -1)
if [[ "$PREV_TAG" == "$TAG_NAME" ]]; then
PREV_TAG=$(git tag -l "plugins/${PLUGIN_NAME}/v*" | sort -V | tail -2 | head -1)
fi
# Only validate changelog changes if there's a previous tag
if [ -n "$PREV_TAG" ]; then
echo "🔍 Previous tag: $PREV_TAG"
# Get message of the tag
echo "🔍 Getting previous tag message..."
PREV_CHANGELOG=$(git tag -l --format='%(contents)' "$PREV_TAG")
echo "📝 Previous changelog body: $PREV_CHANGELOG"
# Checking if tag message is the same as the changelog
if [[ "$PREV_CHANGELOG" == "$CHANGELOG_BODY" ]]; then
echo "❌ Changelog is the same as the previous changelog"
exit 1
fi
else
echo " No previous tag found - this is the first release"
fi
# Create and push tag
echo "🏷️ Creating tag: $TAG_NAME"
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
echo " Tag already exists: $TAG_NAME (skipping creation)"
else
git tag "$TAG_NAME" -m "Release plugin $PLUGIN_NAME v$PLUGIN_VERSION" -m "$CHANGELOG_BODY"
git push origin "$TAG_NAME"
fi
# Create GitHub release
TITLE="Plugin $PLUGIN_NAME v$PLUGIN_VERSION"
# Mark prereleases when version contains a hyphen
PRERELEASE_FLAG=""
if [[ "$PLUGIN_VERSION" == *-* ]]; then
PRERELEASE_FLAG="--prerelease"
fi
# Mark as latest if not a prerelease
LATEST_FLAG=""
if [[ "$PLUGIN_VERSION" != *-* ]]; then
LATEST_FLAG="--latest"
fi
BODY="## Plugin Release: $PLUGIN_NAME v$PLUGIN_VERSION
$CHANGELOG_BODY
### Installation
\`\`\`bash
# Update your go.mod to use the new plugin version
go get github.com/maximhq/bifrost/plugins/$PLUGIN_NAME@v$PLUGIN_VERSION
\`\`\`
---
_This release was automatically created from version file: \`plugins/$PLUGIN_NAME/version\`_"
echo "🎉 Creating GitHub release for $TITLE..."
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
echo " Release $TAG_NAME already exists. Skipping creation."
else
gh release create "$TAG_NAME" \
--title "$TITLE" \
--notes "$BODY" \
${PRERELEASE_FLAG} ${LATEST_FLAG}
fi
echo "✅ Plugin $PLUGIN_NAME released successfully"
echo "success=true" >> "${GITHUB_OUTPUT:-/dev/null}"

77
.github/workflows/scripts/revert-latest.sh vendored Executable file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
# Overwrite latest with a specific version from R2
# Usage: ./revert-latest.sh <version>
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <version> (e.g., v1.2.3)"
exit 1
fi
VERSION="$1"
# Ensure version starts with 'v'
if [[ ! "$VERSION" =~ ^v ]]; then
VERSION="v${VERSION}"
fi
# Validate required environment variables
: "${R2_ENDPOINT:?R2_ENDPOINT env var is required}"
: "${R2_BUCKET:?R2_BUCKET env var is required}"
# Clean endpoint URL
R2_ENDPOINT="$(echo "$R2_ENDPOINT" | tr -d '[:space:]')"
echo "🔄 Reverting latest to version: $VERSION"
# Function to sync with retry logic
sync_with_retry() {
local source_path="$1"
local dest_path="$2"
local max_retries=3
for attempt in $(seq 1 $max_retries); do
echo "🔄 Attempt $attempt/$max_retries: Syncing $source_path to $dest_path"
if aws s3 sync "$source_path" "$dest_path" \
--endpoint-url "$R2_ENDPOINT" \
--profile "${R2_AWS_PROFILE:-R2}" \
--no-progress \
--delete; then
echo "✅ Sync successful from $source_path to $dest_path"
return 0
else
echo "⚠️ Attempt $attempt failed"
if [ $attempt -lt $max_retries ]; then
delay=$((2 ** attempt))
echo "🕐 Waiting ${delay}s before retry..."
sleep $delay
fi
fi
done
echo "❌ All $max_retries attempts failed for syncing to $dest_path"
return 1
}
# Check if the version exists in R2
echo "🔍 Checking if version $VERSION exists..."
if ! aws s3 ls "s3://$R2_BUCKET/bifrost/$VERSION/" \
--endpoint-url "$R2_ENDPOINT" \
--profile "${R2_AWS_PROFILE:-R2}" >/dev/null 2>&1; then
echo "❌ Version $VERSION not found in R2 bucket"
echo "Available versions:"
aws s3 ls "s3://$R2_BUCKET/bifrost/" \
--endpoint-url "$R2_ENDPOINT" \
--profile "${R2_AWS_PROFILE:-R2}" | grep "PRE v" | awk '{print $2}' | sed 's/\///g' || true
exit 1
fi
echo "✅ Version $VERSION found in R2"
# Sync the specific version to latest
if ! sync_with_retry "s3://$R2_BUCKET/bifrost/$VERSION/" "s3://$R2_BUCKET/bifrost/latest/"; then
exit 1
fi
echo "🎉 Successfully reverted latest to version $VERSION"

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env bash
set -euo pipefail
# Run Governance E2E Tests
# This script builds Bifrost, starts it with the governance test config,
# runs the governance tests, and cleans up.
#
# Usage: ./run-governance-e2e-tests.sh
echo "🛡️ Starting Governance E2E Tests..."
# Get the root directory of the repo
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
cd "$REPO_ROOT"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Configuration
BIFROST_PORT=8080
BIFROST_HOST="localhost"
BIFROST_URL="http://${BIFROST_HOST}:${BIFROST_PORT}"
APP_DIR="tests/governance"
BIFROST_BINARY="tmp/bifrost-http"
BIFROST_PID_FILE="/tmp/bifrost-governance-test.pid"
BIFROST_LOG_FILE="/tmp/bifrost-governance-test.log"
MAX_STARTUP_WAIT=30 # seconds
# Cleanup function to ensure Bifrost is stopped
cleanup() {
local exit_code=$?
echo ""
echo -e "${YELLOW}🧹 Cleaning up...${NC}"
# Stop Bifrost if running
if [ -f "$BIFROST_PID_FILE" ]; then
BIFROST_PID=$(cat "$BIFROST_PID_FILE")
if ps -p "$BIFROST_PID" > /dev/null 2>&1; then
echo -e "${CYAN}Stopping Bifrost (PID: $BIFROST_PID)...${NC}"
kill "$BIFROST_PID" 2>/dev/null || true
sleep 2
# Force kill if still running
if ps -p "$BIFROST_PID" > /dev/null 2>&1; then
echo -e "${YELLOW}Force killing Bifrost...${NC}"
kill -9 "$BIFROST_PID" 2>/dev/null || true
fi
fi
rm -f "$BIFROST_PID_FILE"
fi
# Clean up log file
if [ -f "$BIFROST_LOG_FILE" ]; then
echo -e "${CYAN}Bifrost logs saved to: $BIFROST_LOG_FILE${NC}"
fi
# Clean up test database
if [ -f "data/governance-test.db" ]; then
echo -e "${CYAN}Cleaning up test database...${NC}"
rm -f "data/governance-test.db"
fi
if [ $exit_code -eq 0 ]; then
echo -e "${GREEN}✅ Cleanup complete${NC}"
else
echo -e "${RED}❌ Cleanup complete (tests failed)${NC}"
fi
exit $exit_code
}
# Set up trap to cleanup on exit
trap cleanup EXIT INT TERM
# Step 1: Validate prerequisites
echo -e "${CYAN}📋 Step 1: Validating prerequisites...${NC}"
if [ ! -d "$APP_DIR" ]; then
echo -e "${RED}❌ App directory not found: $APP_DIR${NC}"
exit 1
fi
if [ ! -f "$APP_DIR/config.json" ]; then
echo -e "${RED}❌ Config file not found: $APP_DIR/config.json${NC}"
exit 1
fi
# Check required environment variables (OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY)
if [ -z "${OPENAI_API_KEY:-}" ] || [ -z "${ANTHROPIC_API_KEY:-}" ] || [ -z "${OPENROUTER_API_KEY:-}" ]; then
echo -e "${RED}❌ Required environment variables are not set${NC}"
echo -e "${YELLOW}Set them with: export OPENAI_API_KEY='sk-...'${NC}"
echo -e "${YELLOW}Set them with: export ANTHROPIC_API_KEY='sk-...'${NC}"
echo -e "${YELLOW}Set them with: export OPENROUTER_API_KEY='sk-...'${NC}"
exit 1
fi
echo -e "${GREEN}✅ Prerequisites validated${NC}"
# Step 2: Build Bifrost
echo ""
echo -e "${CYAN}📦 Step 2: Building Bifrost...${NC}"
# Use make to build with LOCAL=1 to use the workspace (go.work)
# This ensures we test the local governance plugin code, not the published version
if ! make build LOCAL=1; then
echo -e "${RED}❌ Failed to build Bifrost${NC}"
exit 1
fi
if [ ! -f "$BIFROST_BINARY" ]; then
echo -e "${RED}❌ Bifrost binary not found at: $BIFROST_BINARY${NC}"
exit 1
fi
echo -e "${GREEN}✅ Bifrost built successfully${NC}"
# Step 3: Start Bifrost in background
echo ""
echo -e "${CYAN}🚀 Step 3: Starting Bifrost server...${NC}"
# Ensure data directory exists for SQLite database
mkdir -p data
# Start Bifrost in background
echo -e "${YELLOW}Starting Bifrost on ${BIFROST_URL}...${NC}"
"$BIFROST_BINARY" -app-dir "$APP_DIR" -port "$BIFROST_PORT" -host "$BIFROST_HOST" > "$BIFROST_LOG_FILE" 2>&1 &
BIFROST_PID=$!
echo "$BIFROST_PID" > "$BIFROST_PID_FILE"
echo -e "${CYAN}Bifrost started with PID: $BIFROST_PID${NC}"
# Step 4: Wait for Bifrost to be ready
echo ""
echo -e "${CYAN}⏳ Step 4: Waiting for Bifrost to be ready...${NC}"
WAIT_COUNT=0
until curl -sf "${BIFROST_URL}/health" > /dev/null 2>&1; do
if [ $WAIT_COUNT -ge $MAX_STARTUP_WAIT ]; then
echo -e "${RED}❌ Bifrost failed to start within ${MAX_STARTUP_WAIT} seconds${NC}"
echo -e "${YELLOW}Last 50 lines of Bifrost logs:${NC}"
tail -n 50 "$BIFROST_LOG_FILE" || true
exit 1
fi
# Check if process is still running
if ! ps -p "$BIFROST_PID" > /dev/null 2>&1; then
echo -e "${RED}❌ Bifrost process died${NC}"
echo -e "${YELLOW}Bifrost logs:${NC}"
cat "$BIFROST_LOG_FILE" || true
exit 1
fi
WAIT_COUNT=$((WAIT_COUNT + 1))
echo -e "${YELLOW}Waiting for Bifrost... ($WAIT_COUNT/${MAX_STARTUP_WAIT})${NC}"
sleep 1
done
echo -e "${GREEN}✅ Bifrost is ready and responding${NC}"
# Step 5: Run governance tests
echo ""
echo -e "${CYAN}🧪 Step 5: Running governance tests...${NC}"
cd tests/governance
# Run tests with go test (disable workspace to avoid module conflicts)
echo -e "${YELLOW}Running go test in tests/governance...${NC}"
# Run tests with verbose output and timeout
# Use GOWORK=off to disable the workspace file and test the module independently
# Use -count=1 to disable test cache
GOWORK=off go test -v -timeout 10m -count=1 ./...
TEST_EXIT_CODE=$?
if [ $TEST_EXIT_CODE -ne 0 ]; then
echo -e "${RED}❌ Governance tests failed (exit code: $TEST_EXIT_CODE)${NC}"
else
echo -e "${GREEN}✅ All governance tests passed${NC}"
fi
cd "$REPO_ROOT"
# Step 6: Report results
echo ""
if [ $TEST_EXIT_CODE -eq 0 ]; then
echo -e "${GREEN}═══════════════════════════════════════════════════════${NC}"
echo -e "${GREEN}✅ Governance E2E Tests PASSED${NC}"
echo -e "${GREEN}═══════════════════════════════════════════════════════${NC}"
else
echo -e "${RED}═══════════════════════════════════════════════════════${NC}"
echo -e "${RED}❌ Governance E2E Tests FAILED${NC}"
echo -e "${RED}═══════════════════════════════════════════════════════${NC}"
echo ""
echo -e "${YELLOW}Check logs at: $BIFROST_LOG_FILE${NC}"
fi
exit $TEST_EXIT_CODE

View File

@@ -0,0 +1,299 @@
#!/usr/bin/env bash
set -euo pipefail
# Run integration tests with Bifrost binary and PostgreSQL
# Usage: ./run-integration-tests.sh <bifrost-binary-path> [port]
# Get the absolute path of the script directory
if command -v readlink >/dev/null 2>&1 && readlink -f "$0" >/dev/null 2>&1; then
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
else
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
fi
# Repository root (3 levels up from .github/workflows/scripts)
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd -P)"
# Parse arguments
if [ "${1:-}" = "" ]; then
echo "Usage: $0 <bifrost-binary-path> [port]" >&2
echo "" >&2
echo "Arguments:" >&2
echo " bifrost-binary-path Path to the bifrost-http binary" >&2
echo " port Port to run Bifrost on (default: 8080)" >&2
exit 1
fi
BIFROST_BINARY="$1"
PORT="${2:-8080}"
# PostgreSQL configuration (from environment or defaults)
POSTGRES_HOST="${POSTGRES_HOST:-localhost}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
POSTGRES_USER="${POSTGRES_USER:-bifrost}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-bifrost_password}"
POSTGRES_DB="${POSTGRES_DB:-bifrost}"
POSTGRES_SSLMODE="${POSTGRES_SSLMODE:-disable}"
# Validate binary exists and is executable
if [ ! -f "$BIFROST_BINARY" ]; then
echo "❌ Error: Bifrost binary not found: $BIFROST_BINARY" >&2
exit 1
fi
if [ ! -x "$BIFROST_BINARY" ]; then
echo "❌ Error: Bifrost binary is not executable: $BIFROST_BINARY" >&2
exit 1
fi
echo "🧪 Running Bifrost Integration Tests"
echo " Binary: $BIFROST_BINARY"
echo " Port: $PORT"
# Create temp directory for merged config
TEMP_DIR=$(mktemp -d)
MERGED_CONFIG="$TEMP_DIR/config.json"
echo "📁 Using temp directory: $TEMP_DIR"
# Cleanup function
cleanup() {
local exit_code=$?
echo ""
echo "🧹 Cleaning up..."
# Kill Bifrost server if running
if [ -n "${BIFROST_PID:-}" ]; then
echo " Stopping Bifrost server (PID: $BIFROST_PID)..."
kill "$BIFROST_PID" 2>/dev/null || true
wait "$BIFROST_PID" 2>/dev/null || true
fi
# Remove temp directory
if [ -d "$TEMP_DIR" ]; then
echo " Removing temp directory..."
rm -rf "$TEMP_DIR"
fi
exit $exit_code
}
trap cleanup EXIT
# Create merged config
echo "📝 Creating merged config with PostgreSQL..."
# Base config from tests/integrations
BASE_CONFIG="$REPO_ROOT/tests/integrations/config.json"
if [ ! -f "$BASE_CONFIG" ]; then
echo "❌ Error: Base config not found: $BASE_CONFIG" >&2
exit 1
fi
# Use jq to merge configs if available, otherwise use Python
#
# NOTE: The following config merge INTENTIONALLY OVERWRITES any existing
# config_store and logs_store keys from the base config. This is required
# because:
# 1. Integration tests MUST use the local PostgreSQL instance to validate
# database-related functionality (config persistence, logging, etc.)
# 2. The base config (tests/integrations/config.json) typically has these
# stores disabled; we need to fully replace them with enabled PostgreSQL
# config pointing to the test container.
# 3. Deep-merging is NOT desired here - we need a complete, known-good
# PostgreSQL configuration regardless of what the base config contains.
#
# Edge cases handled:
# - Base config has no store keys: jq/Python adds them (no issue)
# - Base config has stores disabled: fully replaced with enabled PostgreSQL
# - Base config has different store type (e.g., sqlite): fully replaced
# - Base config has partial PostgreSQL config: fully replaced to ensure
# correct credentials for the test container
#
if command -v jq >/dev/null 2>&1; then
# jq '. + {...}' performs shallow merge at top level, fully replacing
# config_store and logs_store keys (intentional - see note above)
jq --arg host "$POSTGRES_HOST" \
--arg port "$POSTGRES_PORT" \
--arg user "$POSTGRES_USER" \
--arg pass "$POSTGRES_PASSWORD" \
--arg db "$POSTGRES_DB" \
--arg ssl "$POSTGRES_SSLMODE" \
'. + {
"config_store": {
"enabled": true,
"type": "postgres",
"config": {
"host": $host,
"port": $port,
"user": $user,
"password": $pass,
"db_name": $db,
"ssl_mode": $ssl
}
},
"logs_store": {
"enabled": true,
"type": "postgres",
"config": {
"host": $host,
"port": $port,
"user": $user,
"password": $pass,
"db_name": $db,
"ssl_mode": $ssl
}
}
}' "$BASE_CONFIG" > "$MERGED_CONFIG"
else
# Fallback to Python if jq is not available
# Same intentional overwrite behavior as jq path (see note above)
python3 - "$BASE_CONFIG" "$MERGED_CONFIG" << 'EOF'
import sys
import json
import os
base_path = sys.argv[1]
merged_path = sys.argv[2]
with open(base_path, "r") as f:
config = json.load(f)
postgres_config = {
"host": os.environ.get("POSTGRES_HOST", "localhost"),
"port": os.environ.get("POSTGRES_PORT", "5432"),
"user": os.environ.get("POSTGRES_USER", "bifrost"),
"password": os.environ.get("POSTGRES_PASSWORD", "bifrost_password"),
"db_name": os.environ.get("POSTGRES_DB", "bifrost"),
"ssl_mode": os.environ.get("POSTGRES_SSLMODE", "disable")
}
# Intentionally overwrite any existing store config to force PostgreSQL
# for integration tests (see detailed note in bash section above)
config["config_store"] = {
"enabled": True,
"type": "postgres",
"config": postgres_config
}
config["logs_store"] = {
"enabled": True,
"type": "postgres",
"config": postgres_config.copy()
}
with open(merged_path, "w") as f:
json.dump(config, f, indent=2)
EOF
fi
echo " ✅ Merged config created at: $MERGED_CONFIG"
# Reset PostgreSQL database
echo "🔄 Resetting PostgreSQL database..."
DOCKER_COMPOSE_FILE="$REPO_ROOT/.github/workflows/configs/docker-compose.yml"
if [ -f "$DOCKER_COMPOSE_FILE" ]; then
POSTGRES_CONTAINER=$(docker compose -f "$DOCKER_COMPOSE_FILE" ps -q postgres 2>/dev/null || true)
if [ -n "$POSTGRES_CONTAINER" ]; then
docker exec "$POSTGRES_CONTAINER" \
psql -U "$POSTGRES_USER" -d postgres -c "DROP DATABASE IF EXISTS $POSTGRES_DB; CREATE DATABASE $POSTGRES_DB;" \
2>/dev/null || echo " ⚠️ Could not reset database (container may not be running)"
echo " ✅ Database reset complete"
else
echo " ⚠️ PostgreSQL container not found, skipping database reset"
fi
else
echo " ⚠️ Docker compose file not found, skipping database reset"
fi
# Start Bifrost server
echo "🚀 Starting Bifrost server..."
SERVER_LOG="$TEMP_DIR/server.log"
"$BIFROST_BINARY" --app-dir "$TEMP_DIR" --port "$PORT" --log-level debug > "$SERVER_LOG" 2>&1 &
BIFROST_PID=$!
echo " Started Bifrost with PID: $BIFROST_PID"
# Wait for server to be ready
echo "⏳ Waiting for Bifrost to start..."
MAX_WAIT=60
ELAPSED=0
SERVER_READY=false
while [ $ELAPSED -lt $MAX_WAIT ]; do
if grep -q "successfully started bifrost" "$SERVER_LOG" 2>/dev/null; then
SERVER_READY=true
echo " ✅ Bifrost started successfully"
break
fi
# Check if server process is still running
if ! kill -0 "$BIFROST_PID" 2>/dev/null; then
echo " ❌ Bifrost process died unexpectedly"
echo " Server log:"
cat "$SERVER_LOG"
exit 1
fi
sleep 1
ELAPSED=$((ELAPSED + 1))
done
if [ "$SERVER_READY" = false ]; then
echo " ❌ Bifrost failed to start within ${MAX_WAIT}s"
echo " Server log:"
cat "$SERVER_LOG"
exit 1
fi
# Set environment variable for tests
export BIFROST_BASE_URL="http://localhost:$PORT"
echo " BIFROST_BASE_URL=$BIFROST_BASE_URL"
# Run Python integration tests
echo ""
echo "🧪 Running Python integration tests..."
echo "="
cd "$REPO_ROOT/tests/integrations"
# Check if uv is available
if command -v uv >/dev/null 2>&1; then
echo "📦 Installing dependencies with uv..."
uv sync --frozen --quiet
echo ""
echo "🏃 Running tests..."
TEST_EXIT_CODE=0
uv run python run_all_tests.py --verbose || TEST_EXIT_CODE=$?
else
echo "⚠️ uv not found, trying pip..."
# Create virtual environment if needed
if [ ! -d ".venv" ]; then
python3 -m venv .venv
fi
source .venv/bin/activate
pip install -q -e .
echo ""
echo "🏃 Running tests..."
TEST_EXIT_CODE=0
python run_all_tests.py --verbose || TEST_EXIT_CODE=$?
fi
echo ""
echo "="
if [ $TEST_EXIT_CODE -eq 0 ]; then
echo "✅ All integration tests passed!"
else
echo "❌ Some integration tests failed (exit code: $TEST_EXIT_CODE)"
fi
# Exit with test result code (cleanup trap will run)
exit $TEST_EXIT_CODE

4226
.github/workflows/scripts/run-migration-tests.sh vendored Executable file

File diff suppressed because it is too large Load Diff

172
.github/workflows/scripts/run-tests.sh vendored Executable file
View File

@@ -0,0 +1,172 @@
#!/usr/bin/env bash
set -euo pipefail
# Comprehensive test runner for Bifrost PR validation
# This script runs all test suites to validate changes
echo "🧪 Starting Bifrost Test Suite..."
echo "=================================="
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Track test results
TESTS_PASSED=0
TESTS_FAILED=0
# Function to report test result
report_result() {
local test_name=$1
local result=$2
if [ "$result" -eq 0 ]; then
echo -e "${GREEN}$test_name passed${NC}"
((TESTS_PASSED++))
else
echo -e "${RED}$test_name failed${NC}"
((TESTS_FAILED++))
fi
}
# 1. Core Build Validation
echo ""
echo "📦 1/5 - Validating Core Build..."
echo "-----------------------------------"
cd core
if go mod download && go build ./...; then
report_result "Core Build" 0
else
report_result "Core Build" 1
fi
cd ..
# 2. Build MCP Test Servers
echo ""
echo "🔌 2/5 - Building MCP Test Servers..."
echo "-----------------------------------"
MCP_BUILD_FAILED=0
for mcp_dir in examples/mcps/*/; do
if [ -d "$mcp_dir" ]; then
mcp_name=$(basename "$mcp_dir")
if [ -f "$mcp_dir/go.mod" ]; then
echo " Building $mcp_name (Go)..."
mkdir -p "$mcp_dir/bin"
if cd "$mcp_dir" && GOWORK=off go build -o "bin/$mcp_name" . && cd - > /dev/null; then
echo -e " ${GREEN}$mcp_name${NC}"
else
echo -e " ${RED}$mcp_name${NC}"
MCP_BUILD_FAILED=1
cd - > /dev/null 2>&1 || true
fi
elif [ -f "$mcp_dir/package.json" ]; then
echo " Building $mcp_name (TypeScript)..."
if cd "$mcp_dir" && npm install --silent && npm run build && cd - > /dev/null; then
echo -e " ${GREEN}$mcp_name${NC}"
else
echo -e " ${RED}$mcp_name${NC}"
MCP_BUILD_FAILED=1
cd - > /dev/null 2>&1 || true
fi
fi
fi
done
report_result "MCP Test Servers Build" $MCP_BUILD_FAILED
# 3. Core Provider Tests
echo ""
echo "🔧 3/5 - Running Core Provider Tests..."
echo "-----------------------------------"
cd core
if go test -v -run . ./...; then
report_result "Core Provider Tests" 0
else
report_result "Core Provider Tests" 1
fi
cd ..
# 4. Governance Tests
echo ""
echo "🛡️ 4/5 - Running Governance Tests..."
echo "-----------------------------------"
if [ -d "tests/governance" ]; then
cd tests/governance
# Check if virtual environment exists, create if not
if [ ! -d "venv" ]; then
echo "Creating Python virtual environment..."
python3 -m venv venv
fi
# Activate virtual environment
source venv/bin/activate
# Install dependencies
echo "Installing Python dependencies..."
pip install -q -r requirements.txt
# Run tests
if pytest -v; then
report_result "Governance Tests" 0
else
report_result "Governance Tests" 1
fi
deactivate
cd ../..
else
echo -e "${YELLOW}⚠️ Governance tests directory not found, skipping...${NC}"
fi
# 5. Integration Tests
echo ""
echo "🔗 5/5 - Running Integration Tests..."
echo "-----------------------------------"
if [ -d "tests/integrations" ]; then
cd tests/integrations
# Check if virtual environment exists, create if not
if [ ! -d "venv" ]; then
echo "Creating Python virtual environment..."
python3 -m venv venv
fi
# Activate virtual environment
source venv/bin/activate
# Install dependencies
echo "Installing Python dependencies..."
pip install -q -r requirements.txt
# Run tests
if python run_all_tests.py; then
report_result "Integration Tests" 0
else
report_result "Integration Tests" 1
fi
deactivate
cd ../..
else
echo -e "${YELLOW}⚠️ Integration tests directory not found, skipping...${NC}"
fi
# Final Summary
echo ""
echo "=================================="
echo "🏁 Test Suite Complete!"
echo "=================================="
echo -e "${GREEN}Passed: $TESTS_PASSED${NC}"
echo -e "${RED}Failed: $TESTS_FAILED${NC}"
echo ""
if [ "$TESTS_FAILED" -gt 0 ]; then
echo -e "${RED}❌ Some tests failed. Please review the output above.${NC}"
exit 1
else
echo -e "${GREEN}✅ All tests passed successfully!${NC}"
exit 0
fi

View File

@@ -0,0 +1,10 @@
module github.com/maximhq/bifrost/tools/schema-sync
go 1.26.2
require golang.org/x/tools v0.30.0
require (
golang.org/x/mod v0.23.0 // indirect
golang.org/x/sync v0.11.0 // indirect
)

View File

@@ -0,0 +1,8 @@
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
export GOTOOLCHAIN=auto
# If go.work exists, skip
if [ -f "go.work" ]; then
echo "🔍 Go workspace already exists, skipping initialization"
return
fi
# Setup Go workspace for CI
# Usage: source setup-go-workspace.sh
echo "🔧 Setting up Go workspace..."
if [ -f "go.work" ]; then
echo "✅ Go workspace already exists, skipping init"
return 0 2>/dev/null || exit 0
fi
go work init
go work use ./core
go work use ./framework
go work use ./plugins/compat
go work use ./plugins/governance
go work use ./plugins/jsonparser
go work use ./plugins/logging
go work use ./plugins/maxim
go work use ./plugins/mocker
go work use ./plugins/otel
go work use ./plugins/prompts
go work use ./plugins/semanticcache
go work use ./plugins/telemetry
go work use ./transports
go work use ./cli
echo "✅ Go workspace initialized"

179
.github/workflows/scripts/test-all-plugins.sh vendored Executable file
View File

@@ -0,0 +1,179 @@
#!/usr/bin/env bash
set -euo pipefail
# Test all plugins
# Usage: ./test-all-plugins.sh [<JSON_ARRAY_OF_PLUGINS>]
# If no argument provided, tests all plugins in the plugins/ directory
# Setup Go workspace for CI
source "$(dirname "$0")/setup-go-workspace.sh"
echo "🧪 Running plugin tests..."
# Cleanup function to ensure Docker services are stopped
cleanup_docker() {
echo "🧹 Cleaning up Docker services..."
if command -v docker-compose >/dev/null 2>&1; then
docker-compose -f tests/docker-compose.yml down 2>/dev/null || true
elif docker compose version >/dev/null 2>&1; then
docker compose -f tests/docker-compose.yml down 2>/dev/null || true
fi
}
# Register cleanup handler to run on script exit (success or failure)
trap cleanup_docker EXIT
# Starting dependencies of plugin tests
echo "🔧 Starting dependencies of plugin tests..."
# Use docker compose (v2) if available, fallback to docker-compose (v1)
if command -v docker-compose >/dev/null 2>&1; then
docker-compose -f tests/docker-compose.yml up -d
elif docker compose version >/dev/null 2>&1; then
docker compose -f tests/docker-compose.yml up -d
else
echo "❌ Neither docker-compose nor docker compose is available"
exit 1
fi
sleep 20
# Determine which plugins to test
if [ $# -gt 0 ] && [ -n "$1" ]; then
CHANGED_PLUGINS_JSON="$1"
# Verify jq is available
if ! command -v jq >/dev/null 2>&1; then
echo "❌ Error: jq is required but not installed"
exit 1
fi
# Validate that the input is valid JSON
if ! echo "$CHANGED_PLUGINS_JSON" | jq empty >/dev/null 2>&1; then
echo "❌ Error: Invalid JSON provided"
exit 1
fi
# No work earlyexit if array is empty
if jq -e 'length==0' <<<"$CHANGED_PLUGINS_JSON" >/dev/null 2>&1; then
echo "⏭️ No plugins to test"
exit 0
fi
# Convert JSON array to bash array
if ! readarray -t PLUGINS < <(echo "$CHANGED_PLUGINS_JSON" | jq -r '.[]' 2>/dev/null); then
echo "❌ Error: Failed to parse plugin names from JSON"
exit 1
fi
else
# Test all plugins in the plugins/ directory
PLUGINS=()
for plugin_dir in plugins/*/; do
if [ -d "$plugin_dir" ] && [ -f "$plugin_dir/go.mod" ]; then
plugin_name=$(basename "$plugin_dir")
PLUGINS+=("$plugin_name")
fi
done
fi
if [ ${#PLUGINS[@]} -eq 0 ]; then
echo "⏭️ No plugins to test"
exit 0
fi
echo "🔌 Testing ${#PLUGINS[@]} plugins:"
for p in "${PLUGINS[@]}"; do
echo "$p"
done
FAILED_PLUGINS=()
SUCCESS_COUNT=0
OVERALL_EXIT_CODE=0
# Test each plugin
for plugin in "${PLUGINS[@]}"; do
echo ""
echo "🔌 Testing plugin: $plugin"
PLUGIN_DIR="plugins/$plugin"
if [ ! -d "$PLUGIN_DIR" ]; then
echo "⚠️ Warning: Plugin directory not found: $PLUGIN_DIR (skipping)"
continue
fi
if [ ! -f "$PLUGIN_DIR/go.mod" ]; then
echo " No go.mod found for $plugin, skipping tests"
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
continue
fi
cd "$PLUGIN_DIR"
# Validate build
echo "🔨 Validating plugin build..."
if ! go build ./...; then
echo "❌ Build failed for plugin: $plugin"
FAILED_PLUGINS+=("$plugin")
OVERALL_EXIT_CODE=1
cd ../..
continue
fi
# Run tests with coverage if any exist
if go list ./... | grep -q .; then
# Run E2E tests for governance plugin (currently disabled)
if [ "$plugin" = "governance" ]; then
echo "🧪 Running governance plugin tests..."
# Governance plugin tests are currently disabled in release script
# Just run regular tests
if go test -v -timeout 20m -coverprofile=coverage.txt -coverpkg=./... ./...; then
echo "✅ Tests passed for: $plugin"
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
else
echo "❌ Tests failed for: $plugin"
FAILED_PLUGINS+=("$plugin")
OVERALL_EXIT_CODE=1
fi
else
echo "🧪 Running plugin tests with coverage..."
if go test -v -timeout 20m -coverprofile=coverage.txt -coverpkg=./... ./...; then
echo "✅ Tests passed for: $plugin"
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
else
echo "❌ Tests failed for: $plugin"
FAILED_PLUGINS+=("$plugin")
OVERALL_EXIT_CODE=1
fi
fi
# Upload coverage to Codecov
if [ -n "${CODECOV_TOKEN:-}" ] && [ -f coverage.txt ]; then
echo "📊 Uploading coverage to Codecov..."
curl -Os https://uploader.codecov.io/latest/linux/codecov
chmod +x codecov
./codecov -t "$CODECOV_TOKEN" -f coverage.txt -F "plugin-${plugin}"
rm -f codecov coverage.txt
else
rm -f coverage.txt
fi
else
echo " No tests found for $plugin"
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
fi
cd ../..
done
# Summary
echo ""
echo "📋 Plugin Test Summary:"
echo " ✅ Successful: $SUCCESS_COUNT/${#PLUGINS[@]}"
echo " ❌ Failed: ${#FAILED_PLUGINS[@]}"
if [ ${#FAILED_PLUGINS[@]} -gt 0 ]; then
echo " Failed plugins: ${FAILED_PLUGINS[*]}"
echo "❌ Plugin tests completed with failures"
exit $OVERALL_EXIT_CODE
else
echo " 🎉 All plugin tests passed!"
echo "✅ Plugin tests completed successfully"
fi

236
.github/workflows/scripts/test-bifrost-http.sh vendored Executable file
View File

@@ -0,0 +1,236 @@
#!/usr/bin/env bash
set -euo pipefail
# Test bifrost-http component
# Usage: ./test-bifrost-http.sh
# Get the absolute path of the script directory
if command -v readlink >/dev/null 2>&1 && readlink -f "$0" >/dev/null 2>&1; then
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
else
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
fi
# Setup Go workspace for CI
source "$(dirname "$0")/setup-go-workspace.sh"
echo "🧪 Running bifrost-http tests..."
# Validate that config.schema.json and values.schema.json are in sync
echo "🔍 Validating schema consistency between config.schema.json and values.schema.json..."
VALIDATE_SCHEMA_SCRIPT="$SCRIPT_DIR/validate-helm-schema.sh"
if [ -f "$VALIDATE_SCHEMA_SCRIPT" ]; then
if ! "$VALIDATE_SCHEMA_SCRIPT"; then
echo "❌ Schema validation failed. The Helm chart values.schema.json is not in sync with config.schema.json"
exit 1
fi
echo "✅ Schema validation passed"
else
echo "⚠️ Warning: validate-helm-schema.sh not found, skipping schema validation"
fi
# Cleanup function to ensure Docker services are stopped
cleanup_docker() {
echo "🧹 Cleaning up Docker services..."
docker compose -f "$CONFIGS_DIR/docker-compose.yml" down 2>/dev/null || true
}
CONFIGS_DIR=".github/workflows/configs"
# Register cleanup handler to run on script exit (success or failure)
trap cleanup_docker EXIT
# Build UI first before we can validate the transport build
echo "🎨 Building UI..."
make build-ui
# Building hello-world plugin
echo "🔨 Building hello-world plugin..."
cd examples/plugins/hello-world
make build
cd ../../..
# Validate transport build
echo "🔨 Validating transport build..."
cd transports
go build ./...
# Run unit tests with coverage
echo "🧪 Running unit tests with coverage..."
go test --race -v -timeout 40m -coverprofile=coverage.txt ./...
# Upload coverage to Codecov
if [ -n "${CODECOV_TOKEN:-}" ]; then
echo "📊 Uploading coverage to Codecov..."
curl -Os https://uploader.codecov.io/latest/linux/codecov
chmod +x codecov
./codecov -t "$CODECOV_TOKEN" -f coverage.txt -F transports
rm -f codecov coverage.txt
else
echo " CODECOV_TOKEN not set, skipping coverage upload"
rm -f coverage.txt
fi
# Build the binary for integration testing
echo "🔨 Building binary for integration testing..."
mkdir -p ../tmp
cd bifrost-http
go build -o ../../tmp/bifrost-http .
cd ..
# Run integration tests with different configurations
echo "🧪 Running integration tests with different configurations..."
CONFIGS_TO_TEST=(
"default"
"emptystate"
"noconfigstorenologstore"
"witconfigstorelogstorepostgres"
"withconfigstore"
"withconfigstorelogsstorepostgres"
"withconfigstorelogsstoresqlite"
"withdynamicplugin"
"withobservability"
"withsemanticcache"
"withpostgresmcpclientsinconfig"
)
TEST_BINARY="../tmp/bifrost-http"
CONFIGS_DIR="../.github/workflows/configs"
# Running docker compose
echo "🐳 Starting Docker services (PostgreSQL, Weaviate, Redis)..."
docker compose -f "$CONFIGS_DIR/docker-compose.yml" up -d
# Wait for services to be healthy with polling
echo "⏳ Waiting for Docker services to be ready..."
MAX_WAIT=300
ELAPSED=0
SERVICES_READY=false
# Get expected number of services
EXPECTED_SERVICES=$(docker compose -f "$CONFIGS_DIR/docker-compose.yml" config --services 2>/dev/null | wc -l | tr -d ' ')
while [ $ELAPSED -lt $MAX_WAIT ]; do
# Get running container count
RUNNING_COUNT=$(docker compose -f "$CONFIGS_DIR/docker-compose.yml" ps --status running -q 2>/dev/null | wc -l | tr -d ' ')
# Check health status: count healthy and unhealthy (starting/unhealthy) services
HEALTH_OUTPUT=$(docker compose -f "$CONFIGS_DIR/docker-compose.yml" ps --format "{{.Name}}:{{.Health}}" 2>/dev/null)
HEALTHY_COUNT=$(echo "$HEALTH_OUTPUT" | grep -c ":healthy") || HEALTHY_COUNT=0
UNHEALTHY_COUNT=$(echo "$HEALTH_OUTPUT" | grep -cE ":(starting|unhealthy)") || UNHEALTHY_COUNT=0
# All services are ready when:
# 1. All expected services are running
# 2. No services are in "starting" or "unhealthy" state
if [ "$RUNNING_COUNT" -eq "$EXPECTED_SERVICES" ] && [ "$UNHEALTHY_COUNT" -eq "0" ]; then
SERVICES_READY=true
echo "✅ All Docker services are ready ($HEALTHY_COUNT with healthchecks, ${ELAPSED}s)"
break
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
echo " ⏳ Waiting for services... ($RUNNING_COUNT/$EXPECTED_SERVICES running, $HEALTHY_COUNT healthy, $UNHEALTHY_COUNT starting, ${ELAPSED}s/${MAX_WAIT}s)"
done
if [ "$SERVICES_READY" = false ]; then
echo "❌ Docker services failed to become healthy within ${MAX_WAIT}s"
echo " Current service status:"
docker compose -f "$CONFIGS_DIR/docker-compose.yml" ps
exit 1
fi
for config in "${CONFIGS_TO_TEST[@]}"; do
echo " 🔍 Testing with config: $config"
config_path="$CONFIGS_DIR/$config"
# Clean up databases before each config test for a clean slate
echo " 🧹 Resetting PostgreSQL database..."
docker exec -e PGPASSWORD=bifrost_password "$(docker compose -f "$CONFIGS_DIR/docker-compose.yml" ps -q postgres)" \
psql -U bifrost -d postgres \
-c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'bifrost' AND pid <> pg_backend_pid();" \
-c "DROP DATABASE IF EXISTS bifrost;" \
-c "CREATE DATABASE bifrost;"
echo " 🧹 Cleaning up SQLite database files for config: $config..."
find "$config_path" -type f \( -name "*.db" -o -name "*.db-shm" -o -name "*.db-wal" \) -delete 2>/dev/null || true
echo " ✅ Database cleanup complete"
if [ ! -d "$config_path" ]; then
echo " ⚠️ Warning: Config directory not found: $config_path (skipping)"
continue
fi
# Create a temporary log file for server output
SERVER_LOG=$(mktemp)
# Start the server in background with a timeout, logging to file and console
timeout 120s $TEST_BINARY --app-dir "$config_path" --port 18080 --log-level debug 2>&1 | tee "$SERVER_LOG" &
SERVER_PID=$!
# Wait for server to be ready by looking for the startup message
echo " ⏳ Waiting for server to start..."
MAX_WAIT=30
ELAPSED=0
SERVER_READY=false
while [ $ELAPSED -lt $MAX_WAIT ]; do
if grep -q "successfully started bifrost, serving UI on http://localhost:18080" "$SERVER_LOG" 2>/dev/null; then
SERVER_READY=true
echo " ✅ Server started successfully with config: $config"
break
fi
# Check if server process is still running
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo " ❌ Server process died before starting with config: $config"
rm -f "$SERVER_LOG"
exit 1
fi
sleep 1
ELAPSED=$((ELAPSED + 1))
done
if [ "$SERVER_READY" = false ]; then
echo " ❌ Server failed to start within ${MAX_WAIT}s with config: $config"
kill $SERVER_PID 2>/dev/null || true
wait $SERVER_PID 2>/dev/null || true
rm -f "$SERVER_LOG"
exit 1
fi
# Run get_curls.sh to test all GET endpoints
echo " 🧪 Running API endpoint tests..."
GET_CURLS_SCRIPT="$SCRIPT_DIR/get_curls.sh"
if [ -f "$GET_CURLS_SCRIPT" ]; then
BASE_URL="http://localhost:18080" "$GET_CURLS_SCRIPT"
CURL_EXIT_CODE=$?
if [ $CURL_EXIT_CODE -eq 0 ]; then
echo " ✅ API endpoint tests passed for config: $config"
else
echo " ❌ API endpoint tests failed for config: $config (exit code: $CURL_EXIT_CODE)"
kill $SERVER_PID 2>/dev/null || true
wait $SERVER_PID 2>/dev/null || true
rm -f "$SERVER_LOG"
exit 1
fi
else
echo " ⚠️ Warning: get_curls.sh not found at $GET_CURLS_SCRIPT (skipping endpoint tests)"
fi
# Kill the server
kill $SERVER_PID 2>/dev/null || true
wait $SERVER_PID 2>/dev/null || true
# Clean up log file
rm -f "$SERVER_LOG"
# Clean up any lingering processes
sleep 1
done
cd ..
echo "✅ Bifrost-HTTP tests completed successfully"

57
.github/workflows/scripts/test-core.sh vendored Executable file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
# Test core component
# Usage: ./test-core.sh
# Setup Go workspace for CI
source "$(dirname "$0")/setup-go-workspace.sh"
echo "🧪 Running core tests..."
# Build MCP test servers for STDIO tests
echo "🔧 Building MCP test servers..."
for mcp_dir in examples/mcps/*/; do
if [ -d "$mcp_dir" ]; then
mcp_name=$(basename "$mcp_dir")
if [ -f "$mcp_dir/go.mod" ]; then
echo " Building $mcp_name (Go)..."
mkdir -p "$mcp_dir/bin"
pushd "$mcp_dir" > /dev/null
GOWORK=off go build -o "bin/$mcp_name" .
popd > /dev/null
elif [ -f "$mcp_dir/package.json" ]; then
echo " Building $mcp_name (TypeScript)..."
pushd "$mcp_dir" > /dev/null
npm install --silent && npm run build
popd > /dev/null
fi
fi
done
echo "✅ MCP test servers built"
# Validate core build
echo "🔨 Validating core build..."
cd core
go mod download
go build ./...
echo "✅ Core build validation successful"
# Run core tests with coverage
echo "🧪 Running core tests with coverage..."
go test -race -timeout 20m -coverprofile=coverage.txt -coverpkg=./... ./...
# Upload coverage to Codecov
if [ -n "${CODECOV_TOKEN:-}" ]; then
echo "📊 Uploading coverage to Codecov..."
curl -Os https://uploader.codecov.io/latest/linux/codecov
chmod +x codecov
./codecov -t "$CODECOV_TOKEN" -f coverage.txt -F core
rm -f codecov coverage.txt
else
echo " CODECOV_TOKEN not set, skipping coverage upload"
rm -f coverage.txt
fi
cd ..
echo "✅ Core tests completed successfully"

301
.github/workflows/scripts/test-docker-image.sh vendored Executable file
View File

@@ -0,0 +1,301 @@
#!/bin/bash
set -e
# Test Docker image by building, starting with docker-compose, and running E2E API tests
# Usage: ./test-docker-image.sh <platform>
# Example: ./test-docker-image.sh linux/amd64
# Get the absolute path of the script directory
if command -v readlink >/dev/null 2>&1 && readlink -f "$0" >/dev/null 2>&1; then
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
else
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
fi
# Repository root (3 levels up from .github/workflows/scripts)
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd -P)"
# Setup Go workspace for CI (go.work is gitignored, must be regenerated)
source "$SCRIPT_DIR/setup-go-workspace.sh"
PLATFORM=${1:-linux/amd64}
ARCH=$(echo "$PLATFORM" | cut -d'/' -f2)
IMAGE_TAG="bifrost-test:ci-${GITHUB_SHA:-local}-${ARCH}"
CONTAINER_NAME="bifrost-test-${ARCH}"
TEST_PORT=8080
DOCKER_COMPOSE_FILE="$REPO_ROOT/tests/docker-compose.yml"
TEMP_DIR=$(mktemp -d)
CONFIG_FILE="$TEMP_DIR/config.json"
echo "=== Testing Docker image for ${PLATFORM} ==="
# Cleanup function
cleanup() {
local exit_code=$?
echo ""
echo "=== Cleaning up ==="
# Stop and remove Bifrost container
echo "Stopping Bifrost container..."
docker stop "${CONTAINER_NAME}" > /dev/null 2>&1 || true
docker rm "${CONTAINER_NAME}" > /dev/null 2>&1 || true
# Stop docker-compose services
echo "Stopping docker-compose services..."
docker compose -f "$DOCKER_COMPOSE_FILE" down -v > /dev/null 2>&1 || true
# Remove test image
echo "Removing test image..."
docker rmi "${IMAGE_TAG}" > /dev/null 2>&1 || true
# Remove temp directory
rm -rf "$TEMP_DIR"
exit $exit_code
}
trap cleanup EXIT
# Build the image using local module sources (pre-release CI builds)
echo "Building Docker image (local modules)..."
docker build \
--platform "${PLATFORM}" \
-f transports/Dockerfile.local \
-t "${IMAGE_TAG}" \
.
echo "Build complete: ${IMAGE_TAG}"
# Start docker-compose services (Postgres, Weaviate, Redis, Qdrant)
echo ""
echo "=== Starting docker-compose services ==="
docker compose -f "$DOCKER_COMPOSE_FILE" up -d
# Wait for Postgres to be ready
echo "Waiting for Postgres to be ready..."
MAX_WAIT=60
ELAPSED=0
while [ $ELAPSED -lt $MAX_WAIT ]; do
if docker compose -f "$DOCKER_COMPOSE_FILE" exec -T postgres pg_isready -U bifrost -d bifrost > /dev/null 2>&1; then
echo "Postgres is ready"
break
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
done
if [ $ELAPSED -ge $MAX_WAIT ]; then
echo "ERROR: Postgres did not become ready within ${MAX_WAIT}s"
docker compose -f "$DOCKER_COMPOSE_FILE" logs postgres
exit 1
fi
# Get the docker network name
NETWORK_NAME=$(docker compose -f "$DOCKER_COMPOSE_FILE" ps --format json | head -1 | jq -r '.Networks' 2>/dev/null || echo "tests_bifrost_network")
if [ -z "$NETWORK_NAME" ] || [ "$NETWORK_NAME" = "null" ]; then
NETWORK_NAME="tests_bifrost_network"
fi
# Generate config.json with all providers and Postgres stores
echo ""
echo "=== Generating config.json ==="
cat > "$CONFIG_FILE" << 'CONFIGEOF'
{
"$schema": "https://www.getbifrost.ai/schema",
"providers": {
"openai": {
"keys": [{ "name": "OpenAI API Key", "value": "env.OPENAI_API_KEY", "weight": 1, "use_for_batch_api": true }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"elevenlabs": {
"keys": [{ "name": "ElevenLabs API Key", "value": "env.ELEVENLABS_API_KEY", "weight": 1, "use_for_batch_api": true }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"xai": {
"keys": [{ "name": "Xai API Key", "value": "env.XAI_API_KEY", "weight": 1, "use_for_batch_api": true }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"huggingface": {
"keys": [{ "name": "Hugging Face API Key", "value": "env.HUGGING_FACE_API_KEY", "weight": 1, "use_for_batch_api": true }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"anthropic": {
"keys": [{ "name": "Anthropic API Key", "value": "env.ANTHROPIC_API_KEY", "weight": 1, "use_for_batch_api": true }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"gemini": {
"keys": [{ "value": "env.GEMINI_API_KEY", "weight": 1, "use_for_batch_api": true, "name": "Gemini API Key" }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"vertex": {
"keys": [{ "name": "Vertex API Key", "vertex_key_config": { "project_id": "env.VERTEX_PROJECT_ID", "region": "env.GOOGLE_LOCATION", "auth_credentials": "env.VERTEX_CREDENTIALS" }, "weight": 1 }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"mistral": {
"keys": [{ "name": "Mistral API Key", "value": "env.MISTRAL_API_KEY", "weight": 1 }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"cohere": {
"keys": [{ "name": "Cohere API Key", "value": "env.COHERE_API_KEY", "weight": 1 }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"groq": {
"keys": [{ "name": "Groq API Key", "value": "env.GROQ_API_KEY", "weight": 1 }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"perplexity": {
"keys": [{ "name": "Perplexity API Key", "value": "env.PERPLEXITY_API_KEY", "weight": 1 }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"cerebras": {
"keys": [{ "name": "Cerebras API Key", "value": "env.CEREBRAS_API_KEY", "weight": 1 }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"openrouter": {
"keys": [{ "name": "OpenRouter API Key", "value": "env.OPENROUTER_API_KEY", "weight": 1 }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"parasail": {
"keys": [{ "name": "Parasail API Key", "value": "env.PARASAIL_API_KEY", "weight": 1 }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"azure": {
"keys": [{ "name": "Azure API Key", "value": "env.AZURE_API_KEY", "azure_key_config": { "endpoint": "env.AZURE_ENDPOINT", "api_version": "env.AZURE_API_VERSION" }, "weight": 1 }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"bedrock": {
"keys": [{ "name": "Bedrock API Key", "bedrock_key_config": { "access_key": "env.AWS_ACCESS_KEY_ID", "secret_key": "env.AWS_SECRET_ACCESS_KEY", "region": "env.AWS_REGION", "arn": "env.AWS_ARN" }, "weight": 1, "use_for_batch_api": true }],
"network_config": { "default_request_timeout_in_seconds": 300 }
},
"replicate": {
"keys": [{ "name": "Replicate API KEY", "value": "env.REPLICATE_API_KEY", "weight": 1.0, "use_for_batch_api": true }]
}
},
"config_store": {
"enabled": true,
"type": "postgres",
"config": {
"host": "postgres",
"port": "5432",
"user": "bifrost",
"password": "bifrost_password",
"db_name": "bifrost",
"ssl_mode": "disable"
}
},
"logs_store": {
"enabled": true,
"type": "postgres",
"config": {
"host": "postgres",
"port": "5432",
"user": "bifrost",
"password": "bifrost_password",
"db_name": "bifrost",
"ssl_mode": "disable"
}
},
"governance": {
"virtual_keys": [
{
"id": "vk-test",
"value": "sk-bf-test-key",
"is_active": true,
"name": "vk-test"
}
]
},
"client": {
"drop_excess_requests": false,
"initial_pool_size": 300,
"allowed_origins": ["http://localhost:3000", "https://localhost:3000"],
"enable_logging": true,
"enforce_governance_header": false,
"allow_direct_keys": false,
"max_request_body_size_mb": 100
},
"encryption_key": ""
}
CONFIGEOF
echo "Config file created at: $CONFIG_FILE"
# Run the Bifrost container connected to the docker-compose network
echo ""
echo "=== Starting Bifrost container ==="
docker run -d \
--name "${CONTAINER_NAME}" \
--platform "${PLATFORM}" \
--network "${NETWORK_NAME}" \
-p ${TEST_PORT}:8080 \
-e APP_PORT=8080 \
-e APP_HOST=0.0.0.0 \
-e OPENAI_API_KEY="${OPENAI_API_KEY:-}" \
-e ELEVENLABS_API_KEY="${ELEVENLABS_API_KEY:-}" \
-e XAI_API_KEY="${XAI_API_KEY:-}" \
-e HUGGING_FACE_API_KEY="${HUGGING_FACE_API_KEY:-}" \
-e ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" \
-e GEMINI_API_KEY="${GEMINI_API_KEY:-}" \
-e VERTEX_PROJECT_ID="${VERTEX_PROJECT_ID:-}" \
-e VERTEX_CREDENTIALS="${VERTEX_CREDENTIALS:-}" \
-e GOOGLE_LOCATION="${GOOGLE_LOCATION:-us-central1}" \
-e MISTRAL_API_KEY="${MISTRAL_API_KEY:-}" \
-e COHERE_API_KEY="${COHERE_API_KEY:-}" \
-e GROQ_API_KEY="${GROQ_API_KEY:-}" \
-e PERPLEXITY_API_KEY="${PERPLEXITY_API_KEY:-}" \
-e CEREBRAS_API_KEY="${CEREBRAS_API_KEY:-}" \
-e OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-}" \
-e PARASAIL_API_KEY="${PARASAIL_API_KEY:-}" \
-e AZURE_API_KEY="${AZURE_API_KEY:-}" \
-e AZURE_ENDPOINT="${AZURE_ENDPOINT:-}" \
-e AZURE_API_VERSION="${AZURE_API_VERSION:-}" \
-e AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-}" \
-e AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-}" \
-e AWS_REGION="${AWS_REGION:-us-east-1}" \
-e AWS_ARN="${AWS_ARN:-}" \
-e REPLICATE_API_KEY="${REPLICATE_API_KEY:-}" \
-v "$CONFIG_FILE:/app/data/config.json:ro" \
"${IMAGE_TAG}"
# Wait for Bifrost to be ready
echo "Waiting for Bifrost to start..."
MAX_WAIT=60
ELAPSED=0
HEALTH_OK=0
while [ $ELAPSED -lt $MAX_WAIT ]; do
if curl -sf "http://localhost:${TEST_PORT}/health" > /dev/null 2>&1; then
echo "Bifrost health check passed (attempt $((ELAPSED/2 + 1)))"
HEALTH_OK=1
break
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
done
if [ $HEALTH_OK -eq 0 ]; then
echo "ERROR: Bifrost health check failed!"
echo "Container logs:"
docker logs "${CONTAINER_NAME}" 2>&1 | tail -100 || true
exit 1
fi
# # Run E2E API tests
# echo ""
# echo "=== Running E2E API tests ==="
# export BIFROST_BASE_URL="http://localhost:${TEST_PORT}"
# export CI=1
# echo pwd: $(pwd)
# # Run the E2E API test scripts (marked as flaky - failures are logged but don't block)
# if ! ./tests/e2e/api/runners/run-newman-inference-tests.sh; then
# echo "WARNING: runners/run-newman-inference-tests.sh failed (flaky test - continuing)"
# fi
# if ! ./tests/e2e/api/run-all-integrations.sh; then
# echo "WARNING: run-all-integrations.sh failed (flaky test - continuing)"
# fi
# if ! ./tests/e2e/api/runners/run-newman-api-tests.sh; then
# echo "WARNING: run-newman-api-tests.sh failed (flaky test - continuing)"
# fi
# echo ""
# echo "=== Docker image E2E API test passed for ${PLATFORM} ==="

180
.github/workflows/scripts/test-e2e-api.sh vendored Executable file
View File

@@ -0,0 +1,180 @@
#!/usr/bin/env bash
set -euo pipefail
# E2E API tests: /v1, /integrations, /api (Newman/Postman).
# Usage:
# ./test-e2e-api.sh # Bifrost already running at BIFROST_BASE_URL
# ./test-e2e-api.sh <bifrost-binary> [port] # Start Bifrost with config, then run tests
# Config: tests/integrations/python/config.json (merged with Postgres when starting server)
# Requires: Newman installed; provider API keys in environment when starting Bifrost
if command -v readlink >/dev/null 2>&1 && readlink -f "$0" >/dev/null 2>&1; then
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
else
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
fi
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd -P)"
E2E_API_DIR="$REPO_ROOT/tests/e2e/api"
E2E_API_CONFIG="$REPO_ROOT/tests/integrations/python/config.json"
export BIFROST_BASE_URL="${BIFROST_BASE_URL:-http://localhost:8080}"
# ----- Optional: start Bifrost if binary path given -----
if [ -n "${1:-}" ]; then
BIFROST_BINARY="$1"
PORT="${2:-8080}"
POSTGRES_HOST="${POSTGRES_HOST:-localhost}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
POSTGRES_USER="${POSTGRES_USER:-bifrost}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-bifrost_password}"
POSTGRES_DB="${POSTGRES_DB:-bifrost}"
POSTGRES_SSLMODE="${POSTGRES_SSLMODE:-disable}"
export POSTGRES_HOST POSTGRES_PORT POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB POSTGRES_SSLMODE
if [ ! -f "$BIFROST_BINARY" ] || [ ! -x "$BIFROST_BINARY" ]; then
echo "❌ Bifrost binary not found or not executable: $BIFROST_BINARY" >&2
exit 1
fi
if [ ! -f "$E2E_API_CONFIG" ]; then
echo "❌ Config not found: $E2E_API_CONFIG" >&2
exit 1
fi
TEMP_DIR=$(mktemp -d)
MERGED_CONFIG="$TEMP_DIR/config.json"
SERVER_LOG="$TEMP_DIR/server.log"
BIFROST_PID=""
cleanup() {
local exit_code=$?
if [ -n "${BIFROST_PID:-}" ] && kill -0 "$BIFROST_PID" 2>/dev/null; then
kill "$BIFROST_PID" 2>/dev/null || true
wait "$BIFROST_PID" 2>/dev/null || true
fi
rm -rf "$TEMP_DIR"
exit $exit_code
}
trap cleanup EXIT
echo "📝 Merged config (providers + Postgres)..."
if command -v jq >/dev/null 2>&1; then
jq --arg host "$POSTGRES_HOST" --arg port "$POSTGRES_PORT" --arg user "$POSTGRES_USER" \
--arg pass "$POSTGRES_PASSWORD" --arg db "$POSTGRES_DB" --arg ssl "$POSTGRES_SSLMODE" \
'. + {
"config_store": {"enabled": true, "type": "postgres", "config": {"host": $host, "port": $port, "user": $user, "password": $pass, "db_name": $db, "ssl_mode": $ssl}},
"logs_store": {"enabled": true, "type": "postgres", "config": {"host": $host, "port": $port, "user": $user, "password": $pass, "db_name": $db, "ssl_mode": $ssl}}
}' "$E2E_API_CONFIG" > "$MERGED_CONFIG"
else
python3 - "$E2E_API_CONFIG" "$MERGED_CONFIG" << 'PYEOF'
import sys, json, os
with open(sys.argv[1]) as f: c = json.load(f)
pg = {"host": os.environ.get("POSTGRES_HOST", "localhost"), "port": os.environ.get("POSTGRES_PORT", "5432"), "user": os.environ.get("POSTGRES_USER", "bifrost"), "password": os.environ.get("POSTGRES_PASSWORD", "bifrost_password"), "db_name": os.environ.get("POSTGRES_DB", "bifrost"), "ssl_mode": os.environ.get("POSTGRES_SSLMODE", "disable")}
c["config_store"] = {"enabled": True, "type": "postgres", "config": pg}
c["logs_store"] = {"enabled": True, "type": "postgres", "config": dict(pg)}
with open(sys.argv[2], "w") as f: json.dump(c, f, indent=2)
PYEOF
fi
echo "🔄 Resetting PostgreSQL database..."
DOCKER_COMPOSE_FILE="$REPO_ROOT/.github/workflows/configs/docker-compose.yml"
if [ -f "$DOCKER_COMPOSE_FILE" ]; then
POSTGRES_CONTAINER=$(docker compose -f "$DOCKER_COMPOSE_FILE" ps -q postgres)
if [ -n "$POSTGRES_CONTAINER" ]; then
ESCAPED_DB_NAME="${POSTGRES_DB//\"/\"\"}"
docker exec "$POSTGRES_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres -c "DROP DATABASE IF EXISTS \"$ESCAPED_DB_NAME\";" 2>/dev/null || true
docker exec "$POSTGRES_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres -c "CREATE DATABASE \"$ESCAPED_DB_NAME\";" 2>/dev/null || true
fi
fi
echo "🚀 Starting Bifrost on port $PORT..."
"$BIFROST_BINARY" --app-dir "$TEMP_DIR" --port "$PORT" --log-level debug > "$SERVER_LOG" 2>&1 &
BIFROST_PID=$!
MAX_WAIT=60
ELAPSED=0
while [ $ELAPSED -lt $MAX_WAIT ]; do
if grep -q "successfully started bifrost" "$SERVER_LOG" 2>/dev/null; then
echo " ✅ Bifrost started"
break
fi
if ! kill -0 "$BIFROST_PID" 2>/dev/null; then
echo " ❌ Bifrost process exited"
cat "$SERVER_LOG"
exit 1
fi
sleep 1
ELAPSED=$((ELAPSED + 1))
done
if [ $ELAPSED -ge $MAX_WAIT ]; then
echo " ❌ Bifrost did not start within ${MAX_WAIT}s"
cat "$SERVER_LOG"
exit 1
fi
export BIFROST_BASE_URL="http://localhost:$PORT"
fi
# ----- Run tests (/v1, /integrations, /api) -----
echo ""
echo "🧪 Running E2E API tests (Newman)"
echo " BIFROST_BASE_URL=$BIFROST_BASE_URL"
echo ""
if ! command -v newman &>/dev/null; then
echo "❌ Newman is not installed. Install with: npm install -g newman" >&2
exit 1
fi
if [ -f "$E2E_API_DIR/setup-plugin.sh" ]; then
echo "📦 Setting up test plugin (optional)..."
"$E2E_API_DIR/setup-plugin.sh" 2>/dev/null || echo " Plugin setup skipped"
fi
if [ -f "$E2E_API_DIR/setup-mcp.sh" ]; then
echo "🔌 Setting up test MCP server (optional)..."
"$E2E_API_DIR/setup-mcp.sh" 2>/dev/null || echo " MCP setup skipped"
fi
echo ""
cd "$E2E_API_DIR"
# In CI (e.g. GitHub Actions), generate HTML reports for artifact upload
REPORT_ARGS=""
if [ "${GITHUB_ACTIONS:-}" = "true" ] || [ "${CI:-0}" = "1" ]; then
REPORT_ARGS="--html"
fi
echo "=========================================="
echo "Running /v1 test suite..."
echo "=========================================="
if ! ./runners/run-newman-inference-tests.sh $REPORT_ARGS; then
echo "❌ /v1 test suite failed"
exit 1
fi
echo "=========================================="
echo "Running /integrations test suites..."
echo "=========================================="
if ! ./runners/run-all-integration-tests.sh $REPORT_ARGS; then
echo "❌ /integrations test suites failed"
exit 1
fi
echo "=========================================="
echo "Running /api test suite..."
echo "=========================================="
if ! ./runners/run-newman-api-tests.sh $REPORT_ARGS; then
echo "❌ /api test suite failed"
exit 1
fi
echo "=========================================="
echo "Running inference features test suite..."
echo "=========================================="
if ! ./runners/run-newman-inference-features-tests.sh $REPORT_ARGS; then
echo "❌ inference features test suite failed"
exit 1
fi
echo ""
echo "✅ All E2E API tests passed (/v1, /integrations, /api, inference features)"

141
.github/workflows/scripts/test-e2e-ui.sh vendored Executable file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env bash
set -euo pipefail
# Test E2E UI with Playwright
# Usage: ./test-e2e-ui.sh
# Setup Go workspace for CI
source "$(dirname "$0")/setup-go-workspace.sh"
echo "🧪 Running E2E UI tests..."
CONFIGS_DIR=".github/workflows/configs"
# Cleanup function to ensure all services are stopped
cleanup() {
echo "🧹 Cleaning up..."
if [ -n "${SERVER_PID:-}" ]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -f "${SERVER_LOG:-}"
docker compose -f "$CONFIGS_DIR/docker-compose.yml" down 2>/dev/null || true
}
# Register cleanup handler to run on script exit (success or failure)
trap cleanup EXIT
# Build UI
echo "🎨 Building UI..."
make build-ui
# Build bifrost-http binary
echo "🔨 Building bifrost-http binary..."
mkdir -p tmp
cd transports/bifrost-http
go build -o ../../tmp/bifrost-http .
cd ../..
# Start Docker services
echo "🐳 Starting Docker services (PostgreSQL, Redis, etc.)..."
docker compose -f "$CONFIGS_DIR/docker-compose.yml" up -d
# Wait for Docker services to be healthy with polling
echo "⏳ Waiting for Docker services to be ready..."
MAX_WAIT=300
ELAPSED=0
SERVICES_READY=false
# Get expected number of services
EXPECTED_SERVICES=$(docker compose -f "$CONFIGS_DIR/docker-compose.yml" config --services 2>/dev/null | wc -l | tr -d ' ')
while [ $ELAPSED -lt $MAX_WAIT ]; do
# Get running container count
RUNNING_COUNT=$(docker compose -f "$CONFIGS_DIR/docker-compose.yml" ps --status running -q 2>/dev/null | wc -l | tr -d ' ')
# Check health status: count healthy and unhealthy (starting/unhealthy) services
HEALTH_OUTPUT=$(docker compose -f "$CONFIGS_DIR/docker-compose.yml" ps --format "{{.Name}}:{{.Health}}" 2>/dev/null)
HEALTHY_COUNT=$(echo "$HEALTH_OUTPUT" | grep -c ":healthy") || HEALTHY_COUNT=0
UNHEALTHY_COUNT=$(echo "$HEALTH_OUTPUT" | grep -cE ":(starting|unhealthy)") || UNHEALTHY_COUNT=0
# All services are ready when:
# 1. All expected services are running
# 2. No services are in "starting" or "unhealthy" state
if [ "$RUNNING_COUNT" -eq "$EXPECTED_SERVICES" ] && [ "$UNHEALTHY_COUNT" -eq "0" ]; then
SERVICES_READY=true
echo "✅ All Docker services are ready ($HEALTHY_COUNT with healthchecks, ${ELAPSED}s)"
break
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
echo " ⏳ Waiting for services... ($RUNNING_COUNT/$EXPECTED_SERVICES running, $HEALTHY_COUNT healthy, $UNHEALTHY_COUNT starting, ${ELAPSED}s/${MAX_WAIT}s)"
done
if [ "$SERVICES_READY" = false ]; then
echo "❌ Docker services failed to become healthy within ${MAX_WAIT}s"
echo " Current service status:"
docker compose -f "$CONFIGS_DIR/docker-compose.yml" ps
exit 1
fi
# Reset PostgreSQL database to clean state
echo "🧹 Resetting PostgreSQL database..."
docker exec -e PGPASSWORD=bifrost_password "$(docker compose -f "$CONFIGS_DIR/docker-compose.yml" ps -q postgres)" \
psql -U bifrost -d postgres \
-c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'bifrost' AND pid <> pg_backend_pid();" \
-c "DROP DATABASE IF EXISTS bifrost;" \
-c "CREATE DATABASE bifrost;"
# Start bifrost-http server with default config
SERVER_LOG=$(mktemp)
echo "🚀 Starting bifrost-http server..."
./tmp/bifrost-http --app-dir "$CONFIGS_DIR/default" --port 18080 --log-level debug 2>&1 | tee "$SERVER_LOG" &
SERVER_PID=$!
# Wait for server to be ready
echo "⏳ Waiting for server to start..."
MAX_WAIT=60
ELAPSED=0
SERVER_READY=false
while [ $ELAPSED -lt $MAX_WAIT ]; do
if grep -q "successfully started bifrost, serving UI on http://localhost:18080" "$SERVER_LOG" 2>/dev/null; then
SERVER_READY=true
echo "✅ Server started successfully"
break
fi
# Check if server process is still running
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "❌ Server process died before starting"
exit 1
fi
sleep 1
ELAPSED=$((ELAPSED + 1))
done
if [ "$SERVER_READY" = false ]; then
echo "❌ Server failed to start within ${MAX_WAIT}s"
exit 1
fi
# Install Playwright dependencies
echo "📦 Installing Playwright dependencies..."
cd tests/e2e
npm ci
npx playwright install --with-deps chromium
# Run Playwright tests (BASE_URL = browser; BIFROST_BASE_URL = global-setup API calls).
# Forward MCP_SSE_HEADERS so the mcp-registry SSE test can use it (set in workflow env).
echo "🎭 Running Playwright E2E tests..."
CI=true SKIP_WEB_SERVER=1 BASE_URL=http://localhost:18080 BIFROST_BASE_URL=http://localhost:18080 \
MCP_SSE_HEADERS="${MCP_SSE_HEADERS:-}" \
npx playwright test --workers=4
PLAYWRIGHT_EXIT=$?
cd ../..
echo "✅ E2E UI tests completed"
exit $PLAYWRIGHT_EXIT

61
.github/workflows/scripts/test-framework.sh vendored Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
# Test framework component
# Usage: ./test-framework.sh
# Setup Go workspace for CI
source "$(dirname "$0")/setup-go-workspace.sh"
echo "🧪 Running framework tests..."
# Cleanup function to ensure Docker services are stopped
cleanup_docker() {
echo "🧹 Cleaning up Docker services..."
if command -v docker-compose >/dev/null 2>&1; then
docker-compose -f tests/docker-compose.yml down 2>/dev/null || true
elif docker compose version >/dev/null 2>&1; then
docker compose -f tests/docker-compose.yml down 2>/dev/null || true
fi
}
# Register cleanup handler to run on script exit (success or failure)
trap cleanup_docker EXIT
# Starting dependencies of framework tests
echo "🔧 Starting dependencies of framework tests..."
# Use docker compose (v2) if available, fallback to docker-compose (v1)
if command -v docker-compose >/dev/null 2>&1; then
docker-compose -f tests/docker-compose.yml up -d
elif docker compose version >/dev/null 2>&1; then
docker compose -f tests/docker-compose.yml up -d
else
echo "❌ Neither docker-compose nor docker compose is available"
exit 1
fi
sleep 20
# Validate framework build
echo "🔨 Validating framework build..."
cd framework
go build ./...
echo "✅ Framework build validation successful"
# Run framework tests with coverage
echo "🧪 Running framework tests with coverage..."
go test --race -coverprofile=coverage.txt -coverpkg=./... ./...
# Upload coverage to Codecov
if [ -n "${CODECOV_TOKEN:-}" ]; then
echo "📊 Uploading coverage to Codecov..."
curl -Os https://uploader.codecov.io/latest/linux/codecov
chmod +x codecov
./codecov -t "$CODECOV_TOKEN" -f coverage.txt -F framework
rm -f codecov coverage.txt
else
echo " CODECOV_TOKEN not set, skipping coverage upload"
rm -f coverage.txt
fi
cd ..
echo "✅ Framework tests completed successfully"

183
.github/workflows/scripts/test-integrations.sh vendored Executable file
View File

@@ -0,0 +1,183 @@
#!/usr/bin/env bash
set -euo pipefail
# Test integration tests by building bifrost-http from source, starting it,
# and running Python and TypeScript SDK integration tests
# Usage: ./test-integrations.sh
# Get the absolute path of the script directory
if command -v readlink >/dev/null 2>&1 && readlink -f "$0" >/dev/null 2>&1; then
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
else
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
fi
# Repository root (3 levels up from .github/workflows/scripts)
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd -P)"
# Setup Go workspace for CI (go.work is gitignored, must be regenerated)
source "$SCRIPT_DIR/setup-go-workspace.sh"
echo "🧪 Running Integration Tests"
echo " Repository root: $REPO_ROOT"
# Configuration
TEST_PORT="${PORT:-8080}"
TEST_HOST="${HOST:-localhost}"
BIFROST_PID=""
TEST_FAILED=0
LOG_FILE="$(mktemp /tmp/bifrost-integrations.XXXXXX.log)"
# Cleanup function
cleanup() {
local exit_code=$?
echo ""
echo "🧹 Cleaning up..."
# Kill Bifrost server if running
if [ -n "${BIFROST_PID:-}" ]; then
echo " Stopping Bifrost server (PID: $BIFROST_PID)..."
kill "$BIFROST_PID" 2>/dev/null || true
wait "$BIFROST_PID" 2>/dev/null || true
fi
rm -f "${LOG_FILE:-}" 2>/dev/null || true
exit $exit_code
}
trap cleanup EXIT
# Step 1: Build bifrost-http from source
echo ""
echo "🔨 Building bifrost-http from source..."
cd "$REPO_ROOT"
# Build the UI first, then the binary
make build-ui
make build
if [ ! -f "$REPO_ROOT/tmp/bifrost-http" ]; then
echo "❌ Error: bifrost-http binary not found at $REPO_ROOT/tmp/bifrost-http"
exit 1
fi
echo "✅ Build complete: $REPO_ROOT/tmp/bifrost-http"
# Step 2: Start Bifrost server with Python integration test config
echo ""
echo "🚀 Starting Bifrost server..."
echo " Config: tests/integrations/python/config.json"
echo " Host: $TEST_HOST"
echo " Port: $TEST_PORT"
# Start server in background with Python config directory
"$REPO_ROOT/tmp/bifrost-http" \
-host "$TEST_HOST" \
-port "$TEST_PORT" \
-log-style json \
-log-level info \
-app-dir "$REPO_ROOT/tests/integrations/python" \
> "$LOG_FILE" 2>&1 &
BIFROST_PID=$!
echo " Started with PID: $BIFROST_PID"
# Wait for server to be ready
echo "⏳ Waiting for Bifrost to be ready..."
MAX_WAIT=30
ELAPSED=0
SERVER_READY=false
while [ $ELAPSED -lt $MAX_WAIT ]; do
if curl --connect-timeout 10 --max-time 20 -sf "http://$TEST_HOST:$TEST_PORT/health" > /dev/null 2>&1; then
SERVER_READY=true
echo "✅ Bifrost is ready (took ${ELAPSED}s)"
break
fi
# Check if server process is still running
if ! kill -0 "$BIFROST_PID" 2>/dev/null; then
echo "❌ Bifrost process died unexpectedly"
exit 1
fi
sleep 1
ELAPSED=$((ELAPSED + 1))
done
if [ "$SERVER_READY" = false ]; then
echo "❌ Bifrost failed to start within ${MAX_WAIT}s"
exit 1
fi
# Set environment variable for tests
export BIFROST_BASE_URL="http://$TEST_HOST:$TEST_PORT"
echo " BIFROST_BASE_URL=$BIFROST_BASE_URL"
# Step 3: Run Python integration tests
echo ""
echo "🐍 Running Python integration tests..."
echo "="
cd "$REPO_ROOT/tests/integrations/python"
# Check if uv is available
if command -v uv >/dev/null 2>&1; then
echo "📦 Installing Python dependencies with uv..."
uv sync --frozen --quiet
echo ""
echo "🏃 Running Python tests..."
if ! uv run pytest -v --tb=short; then
echo "⚠️ Python tests failed"
TEST_FAILED=1
fi
else
echo "⚠️ uv not found, trying pip..."
# Create virtual environment if needed
if [ ! -d ".venv" ]; then
python3 -m venv .venv
fi
source .venv/bin/activate
pip install -q -e .
echo ""
echo "🏃 Running Python tests..."
if ! pytest -v --tb=short; then
echo "⚠️ Python tests failed"
TEST_FAILED=1
fi
fi
# Step 4: Run TypeScript integration tests
echo ""
echo "📘 Running TypeScript integration tests..."
echo "="
cd "$REPO_ROOT/tests/integrations/typescript"
# Install dependencies if needed
if [ ! -d "node_modules" ]; then
echo "📦 Installing TypeScript dependencies with npm..."
npm ci
fi
echo ""
echo "🏃 Running TypeScript tests..."
if ! npm test; then
echo "⚠️ TypeScript tests failed"
TEST_FAILED=1
fi
# Summary
echo ""
echo "="
if [ $TEST_FAILED -eq 1 ]; then
echo "❌ Some integration tests failed"
exit 1
else
echo "✅ All integration tests passed!"
fi

85
.github/workflows/scripts/upload-cli-to-r2.sh vendored Executable file
View File

@@ -0,0 +1,85 @@
#!/usr/bin/env bash
set -euo pipefail
# Upload CLI builds to R2 with retry logic
# Usage: ./upload-cli-to-r2.sh <cli-version>
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <cli-version> (e.g., cli/v1.0.0)"
exit 1
fi
CLI_TAG="$1"
# Validate tag format: must be cli/vX.Y.Z or cli/vX.Y.Z-prerelease
if [[ ! "$CLI_TAG" =~ ^cli/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "❌ Invalid tag format: $CLI_TAG"
echo " Expected format: cli/vX.Y.Z or cli/vX.Y.Z-prerelease (e.g., cli/v1.0.0, cli/v1.0.0-rc.1)"
exit 1
fi
if [[ ! -d "./dist" ]]; then
echo "❌ ./dist not found. Build artifacts must be present before upload."
exit 1
fi
: "${R2_ENDPOINT:?R2_ENDPOINT env var is required}"
: "${R2_BUCKET:?R2_BUCKET env var is required}"
# Strip 'cli/' prefix from version
VERSION_ONLY=${CLI_TAG#cli/v}
CLI_VERSION="v${VERSION_ONLY}"
printf '%s' "$CLI_VERSION" > "./dist/version.txt"
R2_ENDPOINT="$(echo "$R2_ENDPOINT" | tr -d '[:space:]')"
echo "📤 Uploading CLI binaries for version: $CLI_VERSION"
# Function to upload with retry
upload_with_retry() {
local source_path="$1"
local dest_path="$2"
local max_retries=3
for attempt in $(seq 1 $max_retries); do
echo "🔄 Attempt $attempt/$max_retries: Uploading to $dest_path"
if aws s3 sync "$source_path" "$dest_path" \
--endpoint-url "$R2_ENDPOINT" \
--profile "${R2_AWS_PROFILE:-R2}" \
--no-progress \
--delete; then
echo "✅ Upload successful to $dest_path"
return 0
else
echo "⚠️ Attempt $attempt failed"
if [ $attempt -lt $max_retries ]; then
delay=$((2 ** attempt))
echo "🕐 Waiting ${delay}s before retry..."
sleep $delay
fi
fi
done
echo "❌ All $max_retries attempts failed for $dest_path"
return 1
}
# Upload to versioned path
if ! upload_with_retry "./dist/" "s3://$R2_BUCKET/bifrost-cli/$CLI_VERSION/"; then
exit 1
fi
# Check if this is a prerelease version (semver: presence of a hyphen denotes pre-release)
if [[ "$CLI_VERSION" == *-* ]]; then
echo "🔍 Detected prerelease version: $CLI_VERSION"
echo "⏭️ Skipping upload to latest/ for prerelease"
else
echo "🔍 Detected stable release: $CLI_VERSION"
# Small delay between uploads (configurable; default 2s)
sleep "${INTER_UPLOAD_SLEEP_SECONDS:-2}"
# Upload to latest path
echo "📤 Uploading to latest/"
if ! upload_with_retry "./dist/" "s3://$R2_BUCKET/bifrost-cli/latest/"; then
exit 1
fi
fi
echo "🎉 All CLI binaries uploaded successfully to R2"

95
.github/workflows/scripts/upload-to-r2.sh vendored Executable file
View File

@@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -euo pipefail
# Upload builds to R2 with retry logic
# Usage: ./upload-to-r2.sh <transport-version>
#
# Environment variables:
# R2_ENDPOINT - Required. R2 endpoint URL
# R2_BUCKET - Required. R2 bucket name
# R2_AWS_PROFILE - Optional. AWS CLI profile (default: R2)
# SKIP_LATEST_UPLOAD - Optional. Set to "true" to skip latest/ upload
# (used when multiple jobs upload in parallel and
# the finalize job handles latest/ separately)
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <transport-version> (e.g., transports/v1.2.3)"
exit 1
fi
TRANSPORT_VERSION="$1"
if [[ ! -d "./dist" ]]; then
echo "❌ ./dist not found. Build artifacts must be present before upload."
exit 1
fi
: "${R2_ENDPOINT:?R2_ENDPOINT env var is required}"
: "${R2_BUCKET:?R2_BUCKET env var is required}"
# Strip 'transports/' prefix from version
VERSION_ONLY=${TRANSPORT_VERSION#transports/v}
CLI_VERSION="v${VERSION_ONLY}"
R2_ENDPOINT="$(echo "$R2_ENDPOINT" | tr -d '[:space:]')"
echo "📤 Uploading binaries for version: $CLI_VERSION"
# Function to upload with retry
# Uses aws s3 cp --recursive instead of s3 sync --delete so that
# parallel upload jobs don't wipe each other's files.
upload_with_retry() {
local source_path="$1"
local dest_path="$2"
local max_retries=3
for attempt in $(seq 1 $max_retries); do
echo "🔄 Attempt $attempt/$max_retries: Uploading to $dest_path"
if aws s3 cp "$source_path" "$dest_path" \
--endpoint-url "$R2_ENDPOINT" \
--profile "${R2_AWS_PROFILE:-R2}" \
--no-progress \
--recursive; then
echo "✅ Upload successful to $dest_path"
return 0
else
echo "⚠️ Attempt $attempt failed"
if [ $attempt -lt $max_retries ]; then
delay=$((2 ** attempt))
echo "🕐 Waiting ${delay}s before retry..."
sleep $delay
fi
fi
done
echo "❌ All $max_retries attempts failed for $dest_path"
return 1
}
# Upload to versioned path
if ! upload_with_retry "./dist/" "s3://$R2_BUCKET/bifrost/$CLI_VERSION/"; then
exit 1
fi
# Skip latest/ upload if requested (finalize job handles it after all builds complete)
if [[ "${SKIP_LATEST_UPLOAD:-false}" == "true" ]]; then
echo "⏭️ Skipping latest/ upload (will be handled by finalize job)"
echo "🎉 Binaries uploaded successfully to R2 (versioned path)"
exit 0
fi
# Check if this is a prerelease version (semver: presence of a hyphen denotes pre-release)
if [[ "$CLI_VERSION" == *-* ]]; then
echo "🔍 Detected prerelease version: $CLI_VERSION"
echo "⏭️ Skipping upload to latest/ for prerelease"
else
echo "🔍 Detected stable release: $CLI_VERSION"
# Small delay between uploads (configurable; default 2s)
sleep "${INTER_UPLOAD_SLEEP_SECONDS:-2}"
# Upload to latest path
echo "📤 Uploading to latest/"
if ! upload_with_retry "./dist/" "s3://$R2_BUCKET/bifrost/latest/"; then
exit 1
fi
fi
echo "🎉 All binaries uploaded successfully to R2"

View File

@@ -0,0 +1,201 @@
#!/usr/bin/env bash
set -euo pipefail
# Validate that config.schema.json stays in sync with Go struct JSON tags
# Extracts json:"..." tags from Go structs and compares against schema properties
echo "🔍 Validating Go struct fields vs config.schema.json..."
echo "========================================================"
# Get the repository root
if command -v readlink >/dev/null 2>&1 && readlink -f "$0" >/dev/null 2>&1; then
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
else
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
fi
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
CONFIG_SCHEMA="$REPO_ROOT/transports/config.schema.json"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
ERRORS=0
WARNINGS=0
# Check prerequisites
if [ ! -f "$CONFIG_SCHEMA" ]; then
echo "❌ Config schema not found: $CONFIG_SCHEMA"
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "❌ jq is required for Go-to-schema validation"
exit 1
fi
# Extract JSON tags from a Go struct
# Usage: extract_go_json_tags <file> <struct_name>
# Returns sorted list of json tag names (excluding "-" and ",omitempty" suffixes)
extract_go_json_tags() {
local file=$1
local struct_name=$2
awk "/^type ${struct_name} struct/,/^}/" "$file" \
| grep -oE 'json:"([^"]+)"' \
| sed 's/json:"//;s/"//' \
| sed 's/,.*//' \
| grep -v '^-$' \
| sort
}
# Extract property keys from config.schema.json at a given jq path
# Usage: extract_schema_keys <jq_path>
extract_schema_keys() {
local jq_path=$1
jq -r "${jq_path} | keys[]" "$CONFIG_SCHEMA" 2>/dev/null | sort
}
# Compare Go struct tags against schema properties
# Usage: compare_struct_to_schema <label> <go_file> <struct_name> <jq_path> <exclusions...>
compare_struct_to_schema() {
local label=$1
local go_file=$2
local struct_name=$3
local jq_path=$4
shift 4
local exclusions=("$@")
echo ""
echo -e "${CYAN} Checking: $label ($struct_name)${NC}"
if [ ! -f "$go_file" ]; then
echo -e "${RED} ❌ Go file not found: $go_file${NC}"
ERRORS=$((ERRORS + 1))
return
fi
local go_tags
go_tags=$(extract_go_json_tags "$go_file" "$struct_name")
local schema_keys
schema_keys=$(extract_schema_keys "$jq_path")
if [ -z "$go_tags" ]; then
echo -e "${RED} ❌ No JSON tags found for struct $struct_name in $go_file${NC}"
ERRORS=$((ERRORS + 1))
return
fi
if [ -z "$schema_keys" ]; then
echo -e "${RED} ❌ No properties found at $jq_path in config.schema.json${NC}"
ERRORS=$((ERRORS + 1))
return
fi
local has_error=false
# Check Go fields missing from schema
while IFS= read -r tag; do
[ -z "$tag" ] && continue
# Check if excluded
local excluded=false
for exc in "${exclusions[@]+"${exclusions[@]}"}"; do
if [ "$tag" = "$exc" ]; then
excluded=true
break
fi
done
if [ "$excluded" = "true" ]; then
continue
fi
if ! echo "$schema_keys" | grep -qx "$tag"; then
echo -e "${RED} ❌ Go field '$tag' missing from schema ($jq_path)${NC}"
ERRORS=$((ERRORS + 1))
has_error=true
fi
done <<< "$go_tags"
# Check schema fields missing from Go (warnings only)
while IFS= read -r key; do
[ -z "$key" ] && continue
if ! echo "$go_tags" | grep -qx "$key"; then
echo -e "${YELLOW} ⚠️ Schema property '$key' not found in Go struct $struct_name${NC}"
WARNINGS=$((WARNINGS + 1))
fi
done <<< "$schema_keys"
if [ "$has_error" = "false" ]; then
echo -e "${GREEN} ✅ All Go fields present in schema${NC}"
fi
}
echo ""
echo "🔍 Comparing Go struct JSON tags against config.schema.json properties..."
# ClientConfig — framework/configstore/clientconfig.go → .properties.client.properties
compare_struct_to_schema \
"Client Config" \
"$REPO_ROOT/framework/configstore/clientconfig.go" \
"ClientConfig" \
'.properties.client.properties'
# GovernanceConfig — framework/configstore/clientconfig.go → .properties.governance.properties
compare_struct_to_schema \
"Governance Config" \
"$REPO_ROOT/framework/configstore/clientconfig.go" \
"GovernanceConfig" \
'.properties.governance.properties'
# MCPConfig — core/schemas/mcp.go → .properties.mcp.properties
compare_struct_to_schema \
"MCP Config" \
"$REPO_ROOT/core/schemas/mcp.go" \
"MCPConfig" \
'.properties.mcp.properties'
# MCPToolManagerConfig — core/schemas/mcp.go → .$defs.mcp_tool_manager_config.properties
compare_struct_to_schema \
"MCP Tool Manager Config" \
"$REPO_ROOT/core/schemas/mcp.go" \
"MCPToolManagerConfig" \
'."$defs".mcp_tool_manager_config.properties'
# MCPClientConfig — core/schemas/mcp.go → .$defs.mcp_client_config.properties
# Exclude: state (runtime-only), config_hash (internal)
compare_struct_to_schema \
"MCP Client Config" \
"$REPO_ROOT/core/schemas/mcp.go" \
"MCPClientConfig" \
'."$defs".mcp_client_config.properties' \
"state" \
"config_hash"
# PluginConfig — core/schemas/plugin.go → .properties.plugins.items.properties
compare_struct_to_schema \
"Plugin Config" \
"$REPO_ROOT/core/schemas/plugin.go" \
"PluginConfig" \
'.properties.plugins.items.properties'
# Summary
echo ""
echo "========================================================"
echo "🏁 Go-to-Schema Validation Complete!"
echo "========================================================"
echo -e "${GREEN}Errors: $ERRORS${NC}"
echo -e "${YELLOW}Warnings: $WARNINGS${NC}"
echo ""
if [ "$ERRORS" -gt 0 ]; then
echo -e "${RED}❌ Some Go struct fields are missing from config.schema.json.${NC}"
echo " Add the missing properties to transports/config.schema.json"
exit 1
else
echo -e "${GREEN}✅ All Go struct fields are present in config.schema.json!${NC}"
exit 0
fi

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,664 @@
#!/usr/bin/env bash
set -euo pipefail
# Validate that the Helm chart values.schema.json is in sync with config.schema.json
# This script extracts required fields from both schemas and compares them
# Get the repository root
if command -v readlink >/dev/null 2>&1 && readlink -f "$0" >/dev/null 2>&1; then
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
else
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
fi
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
CONFIG_SCHEMA="$REPO_ROOT/transports/config.schema.json"
HELM_SCHEMA="$REPO_ROOT/helm-charts/bifrost/values.schema.json"
echo "📋 Comparing schemas:"
echo " Config schema: $CONFIG_SCHEMA"
echo " Helm schema: $HELM_SCHEMA"
# Check if files exist
if [ ! -f "$CONFIG_SCHEMA" ]; then
echo "❌ Config schema not found: $CONFIG_SCHEMA"
exit 1
fi
if [ ! -f "$HELM_SCHEMA" ]; then
echo "❌ Helm schema not found: $HELM_SCHEMA"
exit 1
fi
# Check if jq is available
if ! command -v jq &> /dev/null; then
echo "⚠️ jq not found, skipping detailed schema comparison"
echo " Install jq for full schema validation"
exit 0
fi
ERRORS=0
# Function to extract required fields from a schema definition
extract_required_fields() {
local schema_file="$1"
local def_path="$2"
jq -r "$def_path.required // [] | .[]" "$schema_file" 2>/dev/null | sort
}
# Function to check if a definition exists in schema
def_exists() {
local schema_file="$1"
local def_path="$2"
jq -e "$def_path" "$schema_file" > /dev/null 2>&1
}
echo ""
echo "🔍 Checking required fields in governance entities..."
# Check governance.budgets required fields
CONFIG_BUDGET_REQUIRED=$(jq -r '.properties.governance.properties.budgets.items.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_BUDGET_REQUIRED=$(jq -r '.properties.bifrost.properties.governance.properties.budgets.items.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_BUDGET_REQUIRED" != "$HELM_BUDGET_REQUIRED" ]; then
echo "❌ Budget required fields mismatch:"
echo " Config: [$CONFIG_BUDGET_REQUIRED]"
echo " Helm: [$HELM_BUDGET_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Budget required fields match: [$CONFIG_BUDGET_REQUIRED]"
fi
# Check governance.rate_limits required fields
CONFIG_RATELIMIT_REQUIRED=$(jq -r '.properties.governance.properties.rate_limits.items.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_RATELIMIT_REQUIRED=$(jq -r '.properties.bifrost.properties.governance.properties.rateLimits.items.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_RATELIMIT_REQUIRED" != "$HELM_RATELIMIT_REQUIRED" ]; then
echo "❌ Rate limits required fields mismatch:"
echo " Config: [$CONFIG_RATELIMIT_REQUIRED]"
echo " Helm: [$HELM_RATELIMIT_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Rate limits required fields match: [$CONFIG_RATELIMIT_REQUIRED]"
fi
# Check governance.customers required fields
CONFIG_CUSTOMER_REQUIRED=$(jq -r '.properties.governance.properties.customers.items.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_CUSTOMER_REQUIRED=$(jq -r '.properties.bifrost.properties.governance.properties.customers.items.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_CUSTOMER_REQUIRED" != "$HELM_CUSTOMER_REQUIRED" ]; then
echo "❌ Customer required fields mismatch:"
echo " Config: [$CONFIG_CUSTOMER_REQUIRED]"
echo " Helm: [$HELM_CUSTOMER_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Customer required fields match: [$CONFIG_CUSTOMER_REQUIRED]"
fi
# Check governance.teams required fields
CONFIG_TEAM_REQUIRED=$(jq -r '.properties.governance.properties.teams.items.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_TEAM_REQUIRED=$(jq -r '.properties.bifrost.properties.governance.properties.teams.items.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_TEAM_REQUIRED" != "$HELM_TEAM_REQUIRED" ]; then
echo "❌ Team required fields mismatch:"
echo " Config: [$CONFIG_TEAM_REQUIRED]"
echo " Helm: [$HELM_TEAM_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Team required fields match: [$CONFIG_TEAM_REQUIRED]"
fi
# Check governance.virtual_keys required fields
CONFIG_VK_REQUIRED=$(jq -r '.properties.governance.properties.virtual_keys.items.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_VK_REQUIRED=$(jq -r '.properties.bifrost.properties.governance.properties.virtualKeys.items.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_VK_REQUIRED" != "$HELM_VK_REQUIRED" ]; then
echo "❌ Virtual key required fields mismatch:"
echo " Config: [$CONFIG_VK_REQUIRED]"
echo " Helm: [$HELM_VK_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Virtual key required fields match: [$CONFIG_VK_REQUIRED]"
fi
echo ""
echo '🔍 Checking required fields in $defs...'
# Check base_key required fields
CONFIG_BASEKEY_REQUIRED=$(jq -r '."$defs".base_key.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_BASEKEY_REQUIRED=$(jq -r '."$defs".providerKey.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_BASEKEY_REQUIRED" != "$HELM_BASEKEY_REQUIRED" ]; then
echo "❌ Provider key (base_key) required fields mismatch:"
echo " Config: [$CONFIG_BASEKEY_REQUIRED]"
echo " Helm: [$HELM_BASEKEY_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Provider key required fields match: [$CONFIG_BASEKEY_REQUIRED]"
fi
# Check azure_key_config required fields
CONFIG_AZURE_REQUIRED=$(jq -r '."$defs".azure_key.allOf[1].properties.azure_key_config.properties | keys | map(select(. as $k | ["endpoint", "api_version"] | index($k))) | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "endpoint,api_version")
HELM_AZURE_REQUIRED=$(jq -r '."$defs".providerKey.properties.azure_key_config.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
# Normalize the comparison (config schema uses allOf pattern)
CONFIG_AZURE_REQ_NORM=$(jq -r '."$defs".azure_key.allOf[1].properties.azure_key_config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
if [ -z "$CONFIG_AZURE_REQ_NORM" ]; then
# Try the direct path in $defs
CONFIG_AZURE_REQ_NORM=$(jq -r '."$defs".azure_key_config.required // ["endpoint", "api_version"] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "api_version,endpoint")
fi
if [ "$CONFIG_AZURE_REQ_NORM" != "$HELM_AZURE_REQUIRED" ]; then
echo "❌ Azure key config required fields mismatch:"
echo " Config: [$CONFIG_AZURE_REQ_NORM]"
echo " Helm: [$HELM_AZURE_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Azure key config required fields match: [$HELM_AZURE_REQUIRED]"
fi
# Check vertex_key_config required fields
CONFIG_VERTEX_REQUIRED=$(jq -r '."$defs".vertex_key.allOf[1].properties.vertex_key_config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_VERTEX_REQUIRED=$(jq -r '."$defs".providerKey.properties.vertex_key_config.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_VERTEX_REQUIRED" != "$HELM_VERTEX_REQUIRED" ]; then
echo "❌ Vertex key config required fields mismatch:"
echo " Config: [$CONFIG_VERTEX_REQUIRED]"
echo " Helm: [$HELM_VERTEX_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Vertex key config required fields match: [$CONFIG_VERTEX_REQUIRED]"
fi
# Check bedrock_key_config required fields
CONFIG_BEDROCK_REQUIRED=$(jq -r '."$defs".bedrock_key.allOf[1].properties.bedrock_key_config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_BEDROCK_REQUIRED=$(jq -r '."$defs".providerKey.properties.bedrock_key_config.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_BEDROCK_REQUIRED" != "$HELM_BEDROCK_REQUIRED" ]; then
echo "❌ Bedrock key config required fields mismatch:"
echo " Config: [$CONFIG_BEDROCK_REQUIRED]"
echo " Helm: [$HELM_BEDROCK_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Bedrock key config required fields match: [$CONFIG_BEDROCK_REQUIRED]"
fi
# Check vllm_key_config required fields
CONFIG_VLLM_REQUIRED=$(jq -r '."$defs".vllm_key.allOf[1].properties.vllm_key_config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_VLLM_REQUIRED=$(jq -r '."$defs".providerKey.properties.vllm_key_config.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_VLLM_REQUIRED" != "$HELM_VLLM_REQUIRED" ]; then
echo "❌ VLLM key config required fields mismatch:"
echo " Config: [$CONFIG_VLLM_REQUIRED]"
echo " Helm: [$HELM_VLLM_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ VLLM key config required fields match: [$HELM_VLLM_REQUIRED]"
fi
# Check concurrency_and_buffer_size required fields (renamed from concurrency_config)
CONFIG_CONCURRENCY_REQUIRED=$(jq -r '."$defs".concurrency_and_buffer_size.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_CONCURRENCY_REQUIRED=$(jq -r '."$defs".concurrencyConfig.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_CONCURRENCY_REQUIRED" != "$HELM_CONCURRENCY_REQUIRED" ]; then
echo "❌ Concurrency config required fields mismatch:"
echo " Config: [$CONFIG_CONCURRENCY_REQUIRED]"
echo " Helm: [$HELM_CONCURRENCY_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Concurrency config required fields match: [$CONFIG_CONCURRENCY_REQUIRED]"
fi
# Check proxy_config required fields
CONFIG_PROXY_REQUIRED=$(jq -r '."$defs".proxy_config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_PROXY_REQUIRED=$(jq -r '."$defs".proxyConfig.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_PROXY_REQUIRED" != "$HELM_PROXY_REQUIRED" ]; then
echo "❌ Proxy config required fields mismatch:"
echo " Config: [$CONFIG_PROXY_REQUIRED]"
echo " Helm: [$HELM_PROXY_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Proxy config required fields match: [$CONFIG_PROXY_REQUIRED]"
fi
# Check mcp_client_config required fields
# Note: Config uses snake_case (connection_type), Helm uses camelCase (connectionType)
CONFIG_MCP_REQUIRED=$(jq -r '."$defs".mcp_client_config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_MCP_REQUIRED=$(jq -r '."$defs".mcpClientConfig.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
# Normalize config snake_case to camelCase for comparison
CONFIG_MCP_NORM=$(echo "$CONFIG_MCP_REQUIRED" | tr ',' '\n' | sed 's/connection_type/connectionType/' | sort | tr '\n' ',' | sed 's/,$//')
if [ "$CONFIG_MCP_NORM" != "$HELM_MCP_REQUIRED" ]; then
echo "❌ MCP client config required fields mismatch:"
echo " Config: [$CONFIG_MCP_REQUIRED] (normalized: [$CONFIG_MCP_NORM])"
echo " Helm: [$HELM_MCP_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ MCP client config required fields match: [$HELM_MCP_REQUIRED]"
fi
# Check provider $def required fields
CONFIG_PROVIDER_REQUIRED=$(jq -r '."$defs".provider.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_PROVIDER_REQUIRED=$(jq -r '."$defs".provider.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_PROVIDER_REQUIRED" != "$HELM_PROVIDER_REQUIRED" ]; then
echo "❌ Provider def required fields mismatch:"
echo " Config: [$CONFIG_PROVIDER_REQUIRED]"
echo " Helm: [$HELM_PROVIDER_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Provider def required fields match: [$CONFIG_PROVIDER_REQUIRED]"
fi
# Check routing_rule required fields
CONFIG_ROUTING_REQUIRED=$(jq -r '."$defs".routing_rule.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_ROUTING_REQUIRED=$(jq -r '.properties.bifrost.properties.governance.properties.routingRules.items.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_ROUTING_REQUIRED" != "$HELM_ROUTING_REQUIRED" ]; then
echo "❌ Routing rule required fields mismatch:"
echo " Config: [$CONFIG_ROUTING_REQUIRED]"
echo " Helm: [$HELM_ROUTING_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Routing rule required fields match: [$CONFIG_ROUTING_REQUIRED]"
fi
echo ""
echo "🔍 Checking required fields in guardrails..."
# Check guardrail_rules required fields
CONFIG_GUARDRAIL_RULE_REQUIRED=$(jq -r '.properties.guardrails_config.properties.guardrail_rules.items.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
# Also check in $defs
if [ -z "$CONFIG_GUARDRAIL_RULE_REQUIRED" ]; then
CONFIG_GUARDRAIL_RULE_REQUIRED=$(jq -r '."$defs".guardrails_config.properties.guardrail_rules.items.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
fi
HELM_GUARDRAIL_RULE_REQUIRED=$(jq -r '.properties.bifrost.properties.guardrails.properties.rules.items.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_GUARDRAIL_RULE_REQUIRED" != "$HELM_GUARDRAIL_RULE_REQUIRED" ]; then
echo "❌ Guardrail rules required fields mismatch:"
echo " Config: [$CONFIG_GUARDRAIL_RULE_REQUIRED]"
echo " Helm: [$HELM_GUARDRAIL_RULE_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Guardrail rules required fields match: [$CONFIG_GUARDRAIL_RULE_REQUIRED]"
fi
# Check guardrail_providers required fields
CONFIG_GUARDRAIL_PROV_REQUIRED=$(jq -r '.properties.guardrails_config.properties.guardrail_providers.items.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
if [ -z "$CONFIG_GUARDRAIL_PROV_REQUIRED" ]; then
CONFIG_GUARDRAIL_PROV_REQUIRED=$(jq -r '."$defs".guardrails_config.properties.guardrail_providers.items.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
fi
HELM_GUARDRAIL_PROV_REQUIRED=$(jq -r '.properties.bifrost.properties.guardrails.properties.providers.items.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_GUARDRAIL_PROV_REQUIRED" != "$HELM_GUARDRAIL_PROV_REQUIRED" ]; then
echo "❌ Guardrail providers required fields mismatch:"
echo " Config: [$CONFIG_GUARDRAIL_PROV_REQUIRED]"
echo " Helm: [$HELM_GUARDRAIL_PROV_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Guardrail providers required fields match: [$CONFIG_GUARDRAIL_PROV_REQUIRED]"
fi
echo ""
echo "🔍 Checking required fields in cluster config..."
# Check cluster gossip required fields (port, config)
CONFIG_GOSSIP_TOP_REQUIRED=$(jq -r '."$defs".cluster_config.properties.gossip.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_GOSSIP_TOP_REQUIRED=$(jq -r '.properties.bifrost.properties.cluster.properties.gossip.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_GOSSIP_TOP_REQUIRED" != "$HELM_GOSSIP_TOP_REQUIRED" ]; then
echo "❌ Cluster gossip required fields mismatch:"
echo " Config: [$CONFIG_GOSSIP_TOP_REQUIRED]"
echo " Helm: [$HELM_GOSSIP_TOP_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Cluster gossip required fields match: [$CONFIG_GOSSIP_TOP_REQUIRED]"
fi
# Check cluster gossip config required fields (timeout_seconds, success_threshold, failure_threshold)
CONFIG_GOSSIP_REQUIRED=$(jq -r '."$defs".cluster_config.properties.gossip.properties.config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_GOSSIP_REQUIRED=$(jq -r '.properties.bifrost.properties.cluster.properties.gossip.properties.config.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
# Normalize field names (config uses snake_case, helm uses camelCase)
CONFIG_GOSSIP_NORM=$(echo "$CONFIG_GOSSIP_REQUIRED" | tr ',' '\n' | sed 's/failure_threshold/failureThreshold/;s/success_threshold/successThreshold/;s/timeout_seconds/timeoutSeconds/' | sort | tr '\n' ',' | sed 's/,$//')
if [ "$CONFIG_GOSSIP_NORM" != "$HELM_GOSSIP_REQUIRED" ]; then
echo "❌ Cluster gossip config required fields mismatch:"
echo " Config: [$CONFIG_GOSSIP_REQUIRED] (normalized: [$CONFIG_GOSSIP_NORM])"
echo " Helm: [$HELM_GOSSIP_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Cluster gossip config required fields match: [$HELM_GOSSIP_REQUIRED]"
fi
echo ""
echo "🔍 Checking required fields in virtual_key_provider_config..."
# Check virtual_key_provider_config required fields
CONFIG_VKPC_REQUIRED=$(jq -r '."$defs".virtual_key_provider_config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_VKPC_REQUIRED=$(jq -r '."$defs".virtualKeyProviderConfig.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_VKPC_REQUIRED" != "$HELM_VKPC_REQUIRED" ]; then
echo "❌ Virtual key provider config required fields mismatch:"
echo " Config: [$CONFIG_VKPC_REQUIRED]"
echo " Helm: [$HELM_VKPC_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Virtual key provider config required fields match: [$CONFIG_VKPC_REQUIRED]"
fi
# Check virtual_key_provider_config keys items required fields (key_id, name, value)
CONFIG_VKPC_KEY_REQUIRED=$(jq -r '."$defs".virtual_key_provider_config.properties.keys.items.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_VKPC_KEY_REQUIRED=$(jq -r '."$defs".virtualKeyProviderConfig.properties.keys.items.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_VKPC_KEY_REQUIRED" != "$HELM_VKPC_KEY_REQUIRED" ]; then
echo "❌ VK provider config key items required fields mismatch:"
echo " Config: [$CONFIG_VKPC_KEY_REQUIRED]"
echo " Helm: [$HELM_VKPC_KEY_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ VK provider config key items required fields match: [$CONFIG_VKPC_KEY_REQUIRED]"
fi
# Check VK provider config key azure_key_config required fields
CONFIG_VKPC_AZURE_REQUIRED=$(jq -r '."$defs".virtual_key_provider_config.properties.keys.items.properties.azure_key_config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_VKPC_AZURE_REQUIRED=$(jq -r '."$defs".virtualKeyProviderConfig.properties.keys.items.properties.azure_key_config.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_VKPC_AZURE_REQUIRED" != "$HELM_VKPC_AZURE_REQUIRED" ]; then
echo "❌ VK provider config key azure_key_config required fields mismatch:"
echo " Config: [$CONFIG_VKPC_AZURE_REQUIRED]"
echo " Helm: [$HELM_VKPC_AZURE_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ VK provider config key azure_key_config required fields match: [$CONFIG_VKPC_AZURE_REQUIRED]"
fi
# Check VK provider config key vertex_key_config required fields
CONFIG_VKPC_VERTEX_REQUIRED=$(jq -r '."$defs".virtual_key_provider_config.properties.keys.items.properties.vertex_key_config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_VKPC_VERTEX_REQUIRED=$(jq -r '."$defs".virtualKeyProviderConfig.properties.keys.items.properties.vertex_key_config.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_VKPC_VERTEX_REQUIRED" != "$HELM_VKPC_VERTEX_REQUIRED" ]; then
echo "❌ VK provider config key vertex_key_config required fields mismatch:"
echo " Config: [$CONFIG_VKPC_VERTEX_REQUIRED]"
echo " Helm: [$HELM_VKPC_VERTEX_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ VK provider config key vertex_key_config required fields match: [$CONFIG_VKPC_VERTEX_REQUIRED]"
fi
# Check VK provider config key vllm_key_config required fields
CONFIG_VKPC_VLLM_REQUIRED=$(jq -r '."$defs".virtual_key_provider_config.properties.keys.items.properties.vllm_key_config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_VKPC_VLLM_REQUIRED=$(jq -r '."$defs".virtualKeyProviderConfig.properties.keys.items.properties.vllm_key_config.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_VKPC_VLLM_REQUIRED" != "$HELM_VKPC_VLLM_REQUIRED" ]; then
echo "❌ VK provider config key vllm_key_config required fields mismatch:"
echo " Config: [$CONFIG_VKPC_VLLM_REQUIRED]"
echo " Helm: [$HELM_VKPC_VLLM_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ VK provider config key vllm_key_config required fields match: [$CONFIG_VKPC_VLLM_REQUIRED]"
fi
echo ""
echo "🔍 Checking required fields in virtual key MCP config..."
# Check virtual_key_mcp_config required fields
CONFIG_VK_MCP_REQUIRED=$(jq -r '."$defs".virtual_key_mcp_config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_VK_MCP_REQUIRED=$(jq -r '.properties.bifrost.properties.governance.properties.virtualKeys.items.properties.mcp_configs.items.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_VK_MCP_REQUIRED" != "$HELM_VK_MCP_REQUIRED" ]; then
echo "❌ Virtual key MCP config required fields mismatch:"
echo " Config: [$CONFIG_VK_MCP_REQUIRED]"
echo " Helm: [$HELM_VK_MCP_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Virtual key MCP config required fields match: [$CONFIG_VK_MCP_REQUIRED]"
fi
echo ""
echo "🔍 Checking required fields in MCP sub-configs..."
# Check MCP stdio_config required fields
CONFIG_MCP_STDIO_REQUIRED=$(jq -r '."$defs".mcp_client_config.properties.stdio_config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_MCP_STDIO_REQUIRED=$(jq -r '."$defs".mcpClientConfig.properties.stdioConfig.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_MCP_STDIO_REQUIRED" != "$HELM_MCP_STDIO_REQUIRED" ]; then
echo "❌ MCP stdio config required fields mismatch:"
echo " Config: [$CONFIG_MCP_STDIO_REQUIRED]"
echo " Helm: [$HELM_MCP_STDIO_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ MCP stdio config required fields match: [$CONFIG_MCP_STDIO_REQUIRED]"
fi
# MCP websocket_config and http_config were removed from config.schema.json
# because the corresponding Go fields don't exist (MCP rendering uses
# connection_type + connection_string directly, not sub-object configs).
# Helm still declares them for user convenience — not a schema sync concern.
echo ""
echo "🔍 Checking required fields in SAML/SCIM config..."
# Check okta_config required fields
CONFIG_OKTA_REQUIRED=$(jq -r '."$defs".okta_config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_OKTA_REQUIRED=$(jq -r '.properties.bifrost.properties.scim.allOf[0].then.properties.config.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_OKTA_REQUIRED" != "$HELM_OKTA_REQUIRED" ]; then
echo "❌ Okta config required fields mismatch:"
echo " Config: [$CONFIG_OKTA_REQUIRED]"
echo " Helm: [$HELM_OKTA_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Okta config required fields match: [$CONFIG_OKTA_REQUIRED]"
fi
# Check entra_config required fields
CONFIG_ENTRA_REQUIRED=$(jq -r '."$defs".entra_config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_ENTRA_REQUIRED=$(jq -r '.properties.bifrost.properties.scim.allOf[1].then.properties.config.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_ENTRA_REQUIRED" != "$HELM_ENTRA_REQUIRED" ]; then
echo "❌ Entra config required fields mismatch:"
echo " Config: [$CONFIG_ENTRA_REQUIRED]"
echo " Helm: [$HELM_ENTRA_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Entra config required fields match: [$CONFIG_ENTRA_REQUIRED]"
fi
echo ""
echo "🔍 Checking required fields in plugin configs..."
# Check semantic cache plugin required fields (dimension)
# Config uses an allOf pattern on plugins array items; Helm uses conditional on semanticCache.enabled
CONFIG_SEMCACHE_REQUIRED=$(jq -r '.properties.plugins.items.allOf[] | select(.if.properties.name.const == "semantic_cache") | .then.properties.config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_SEMCACHE_REQUIRED=$(jq -r '.properties.bifrost.properties.plugins.properties.semanticCache.then.properties.config.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_SEMCACHE_REQUIRED" != "$HELM_SEMCACHE_REQUIRED" ]; then
echo "❌ Semantic cache plugin config required fields mismatch:"
echo " Config: [$CONFIG_SEMCACHE_REQUIRED]"
echo " Helm: [$HELM_SEMCACHE_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Semantic cache plugin config required fields match: [$CONFIG_SEMCACHE_REQUIRED]"
fi
# Check OTEL plugin required fields (collector_url, trace_type, protocol)
CONFIG_OTEL_REQUIRED=$(jq -r '.properties.plugins.items.allOf[] | select(.if.properties.name.const == "otel") | .then.properties.config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_OTEL_REQUIRED=$(jq -r '.properties.bifrost.properties.plugins.properties.otel.then.properties.config.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_OTEL_REQUIRED" != "$HELM_OTEL_REQUIRED" ]; then
echo "❌ OTEL plugin config required fields mismatch:"
echo " Config: [$CONFIG_OTEL_REQUIRED]"
echo " Helm: [$HELM_OTEL_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ OTEL plugin config required fields match: [$CONFIG_OTEL_REQUIRED]"
fi
# Check telemetry push_gateway required fields
CONFIG_PUSHGW_REQUIRED=$(jq -r '.properties.plugins.items.allOf[] | select(.if.properties.name.const == "telemetry") | .then.properties.config.properties.push_gateway.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_PUSHGW_REQUIRED=$(jq -r '.properties.bifrost.properties.plugins.properties.telemetry.properties.config.properties.push_gateway.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_PUSHGW_REQUIRED" != "$HELM_PUSHGW_REQUIRED" ]; then
echo "❌ Telemetry push_gateway required fields mismatch:"
echo " Config: [$CONFIG_PUSHGW_REQUIRED]"
echo " Helm: [$HELM_PUSHGW_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Telemetry push_gateway required fields match: [$CONFIG_PUSHGW_REQUIRED]"
fi
# Check telemetry push_gateway basic_auth required fields
CONFIG_PUSHGW_AUTH_REQUIRED=$(jq -r '.properties.plugins.items.allOf[] | select(.if.properties.name.const == "telemetry") | .then.properties.config.properties.push_gateway.properties.basic_auth.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_PUSHGW_AUTH_REQUIRED=$(jq -r '.properties.bifrost.properties.plugins.properties.telemetry.properties.config.properties.push_gateway.properties.basic_auth.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_PUSHGW_AUTH_REQUIRED" != "$HELM_PUSHGW_AUTH_REQUIRED" ]; then
echo "❌ Telemetry push_gateway basic_auth required fields mismatch:"
echo " Config: [$CONFIG_PUSHGW_AUTH_REQUIRED]"
echo " Helm: [$HELM_PUSHGW_AUTH_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Telemetry push_gateway basic_auth required fields match: [$CONFIG_PUSHGW_AUTH_REQUIRED]"
fi
# Check plugin array items required fields (enabled, name)
# Config defines plugins as an array; Helm splits into named plugins + a "custom" array
CONFIG_PLUGIN_ITEMS_REQUIRED=$(jq -r '.properties.plugins.items.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_PLUGIN_ITEMS_REQUIRED=$(jq -r '.properties.bifrost.properties.plugins.properties.custom.items.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_PLUGIN_ITEMS_REQUIRED" != "$HELM_PLUGIN_ITEMS_REQUIRED" ]; then
echo "❌ Plugin items required fields mismatch:"
echo " Config (plugins.items): [$CONFIG_PLUGIN_ITEMS_REQUIRED]"
echo " Helm (plugins.custom.items): [$HELM_PLUGIN_ITEMS_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Plugin items required fields match: [$CONFIG_PLUGIN_ITEMS_REQUIRED]"
fi
# Check plugin item properties completeness (all config properties must exist in helm custom items)
echo ""
echo "🔍 Checking plugin item property completeness..."
CONFIG_PLUGIN_PROPS=$(jq -r '.properties.plugins.items.properties | keys | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_CUSTOM_PLUGIN_PROPS=$(jq -r '.properties.bifrost.properties.plugins.properties.custom.items.properties | keys | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
# Check each config property exists in helm custom items
for prop in $(echo "$CONFIG_PLUGIN_PROPS" | tr ',' '\n'); do
if ! echo "$HELM_CUSTOM_PLUGIN_PROPS" | tr ',' '\n' | grep -qx "$prop"; then
echo "❌ Plugin property '$prop' exists in config.schema.json but missing from helm custom plugin items"
ERRORS=$((ERRORS + 1))
else
echo "✅ Plugin property '$prop' present in both schemas"
fi
done
# Verify placement enum values match
CONFIG_PLACEMENT_ENUM=$(jq -r '.properties.plugins.items.properties.placement.enum // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_PLACEMENT_ENUM=$(jq -r '.properties.bifrost.properties.plugins.properties.custom.items.properties.placement.enum // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_PLACEMENT_ENUM" != "$HELM_PLACEMENT_ENUM" ]; then
echo "❌ Plugin placement enum mismatch:"
echo " Config: [$CONFIG_PLACEMENT_ENUM]"
echo " Helm: [$HELM_PLACEMENT_ENUM]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Plugin placement enum values match: [$CONFIG_PLACEMENT_ENUM]"
fi
# Check maxim plugin config required fields (api_key)
# Note: Helm allows either config.api_key OR secretRef.name via anyOf
CONFIG_MAXIM_REQUIRED=$(jq -r '.properties.plugins.items.allOf[] | select(.if.properties.name.const == "maxim") | .then.properties.config.required // [] | sort | join(",")' "$CONFIG_SCHEMA" 2>/dev/null || echo "")
HELM_MAXIM_REQUIRED=$(jq -r '.properties.bifrost.properties.plugins.properties.maxim.then.anyOf[0].properties.config.required // [] | sort | join(",")' "$HELM_SCHEMA" 2>/dev/null || echo "")
if [ "$CONFIG_MAXIM_REQUIRED" != "$HELM_MAXIM_REQUIRED" ]; then
echo "❌ Maxim plugin config required fields mismatch:"
echo " Config: [$CONFIG_MAXIM_REQUIRED]"
echo " Helm (anyOf[0]): [$HELM_MAXIM_REQUIRED]"
ERRORS=$((ERRORS + 1))
else
echo "✅ Maxim plugin config required fields match: [$CONFIG_MAXIM_REQUIRED]"
fi
echo ""
echo "🔍 Checking property existence for Gap 1-8 fields..."
# Helper function to check a property exists in a schema
check_property_exists() {
local label=$1
local jq_path=$2
local schema_file=$3
if ! jq -e "$jq_path" "$schema_file" > /dev/null 2>&1; then
echo " ❌ Missing: $label"
ERRORS=$((ERRORS + 1))
else
echo " ✅ Present: $label"
fi
}
# Gap 1+2: Client properties in Helm schema
echo ""
echo " Checking client properties (Gap 1+2)..."
for prop in asyncJobResultTTL requiredHeaders loggingHeaders allowedHeaders mcpAgentDepth mcpToolExecutionTimeout mcpCodeModeBindingLevel mcpToolSyncInterval hideDeletedVirtualKeysInFilters; do
check_property_exists "client.$prop" ".properties.bifrost.properties.client.properties.${prop}" "$HELM_SCHEMA"
done
# Gap 3: OTel plugin config properties
echo ""
echo " Checking OTel plugin properties (Gap 3)..."
for prop in headers tls_ca_cert insecure; do
check_property_exists "otel.config.$prop" ".properties.bifrost.properties.plugins.properties.otel.properties.config.properties.${prop}" "$HELM_SCHEMA"
done
# Gap 4: Governance plugin config properties
echo ""
echo " Checking governance plugin properties (Gap 4)..."
for prop in required_headers is_enterprise; do
check_property_exists "governance.plugin.config.$prop" ".properties.bifrost.properties.plugins.properties.governance.properties.config.properties.${prop}" "$HELM_SCHEMA"
done
# Gap 5: Governance top-level properties
echo ""
echo " Checking governance top-level properties (Gap 5)..."
for prop in modelConfigs providers; do
check_property_exists "governance.$prop" ".properties.bifrost.properties.governance.properties.${prop}" "$HELM_SCHEMA"
done
# Gap 6: MCP properties
echo ""
echo " Checking MCP properties (Gap 6)..."
check_property_exists "mcp.toolSyncInterval" ".properties.bifrost.properties.mcp.properties.toolSyncInterval" "$HELM_SCHEMA"
check_property_exists "mcp.toolManagerConfig.codeModeBindingLevel" '.properties.bifrost.properties.mcp.properties.toolManagerConfig.properties.codeModeBindingLevel' "$HELM_SCHEMA"
for prop in clientId isCodeModeClient toolSyncInterval isPingAvailable; do
check_property_exists "mcpClientConfig.$prop" '.["$defs"].mcpClientConfig.properties.'"${prop}" "$HELM_SCHEMA"
done
# Gap 7: Cluster properties
echo ""
echo " Checking cluster properties (Gap 7)..."
check_property_exists "cluster.region" ".properties.bifrost.properties.cluster.properties.region" "$HELM_SCHEMA"
# Gap 8: Miscellaneous properties
echo ""
echo " Checking miscellaneous properties (Gap 8)..."
check_property_exists "telemetry.custom_labels" ".properties.bifrost.properties.plugins.properties.telemetry.properties.config.properties.custom_labels" "$HELM_SCHEMA"
check_property_exists "semanticCache.default_cache_key" ".properties.bifrost.properties.plugins.properties.semanticCache.properties.config.properties.default_cache_key" "$HELM_SCHEMA"
# Also verify these exist in config.schema.json
echo ""
echo " Checking config.schema.json has is_ping_available + tool_pricing..."
check_property_exists "mcp_client_config.is_ping_available" '."$defs".mcp_client_config.properties.is_ping_available' "$CONFIG_SCHEMA"
check_property_exists "mcp_client_config.tool_pricing" '."$defs".mcp_client_config.properties.tool_pricing' "$CONFIG_SCHEMA"
echo ""
if [ $ERRORS -gt 0 ]; then
echo "❌ Schema validation failed with $ERRORS error(s)"
echo ""
echo "To fix these errors, update helm-charts/bifrost/values.schema.json to match"
echo "the required fields in transports/config.schema.json"
exit 1
fi
echo "✅ All schema validations passed!"
exit 0

View File

@@ -0,0 +1,437 @@
#!/usr/bin/env bash
set -euo pipefail
# Helm template validation script for Bifrost
# Validates all storage and vector store combinations render correctly
echo "🔍 Validating Helm Chart Templates..."
echo "======================================"
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Track test results
TESTS_PASSED=0
TESTS_FAILED=0
# Function to report test result
report_result() {
local test_name=$1
local result=$2
if [ "$result" -eq 0 ]; then
echo -e "${GREEN}$test_name${NC}"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
echo -e "${RED}$test_name${NC}"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
}
# Function to test a helm template combination
test_template() {
local test_name=$1
shift
local helm_args=("$@")
if helm template bifrost ./helm-charts/bifrost \
--set image.tag=v1.0.0 \
"${helm_args[@]}" \
> /tmp/helm-template-output.yaml 2>&1; then
report_result "$test_name" 0
return 0
else
report_result "$test_name" 1
echo -e "${YELLOW} Error output:${NC}"
head -10 /tmp/helm-template-output.yaml | sed 's/^/ /'
return 1
fi
}
# 1. Storage Combinations (9 tests)
echo ""
echo -e "${CYAN}📦 1/6 - Testing Storage Combinations (9 tests)...${NC}"
echo "---------------------------------------------------"
# config=no, logs=no
test_template "config=no, logs=no" \
--set storage.configStore.enabled=false \
--set storage.logsStore.enabled=false \
--set postgresql.enabled=false
# config=no, logs=sqlite
test_template "config=no, logs=sqlite" \
--set storage.configStore.enabled=false \
--set storage.logsStore.enabled=true \
--set storage.mode=sqlite \
--set postgresql.enabled=false
# config=no, logs=postgres
test_template "config=no, logs=postgres" \
--set storage.configStore.enabled=false \
--set storage.logsStore.enabled=true \
--set storage.mode=postgres \
--set postgresql.enabled=true \
--set postgresql.auth.password=testpass
# config=sqlite, logs=no
test_template "config=sqlite, logs=no" \
--set storage.configStore.enabled=true \
--set storage.logsStore.enabled=false \
--set storage.mode=sqlite \
--set postgresql.enabled=false
# config=sqlite, logs=sqlite
test_template "config=sqlite, logs=sqlite" \
--set storage.configStore.enabled=true \
--set storage.logsStore.enabled=true \
--set storage.mode=sqlite \
--set postgresql.enabled=false
# config=sqlite, logs=postgres
test_template "config=sqlite, logs=postgres" \
--set storage.configStore.enabled=true \
--set storage.logsStore.enabled=true \
--set storage.mode=postgres \
--set postgresql.enabled=true \
--set postgresql.auth.password=testpass
# config=postgres, logs=no
test_template "config=postgres, logs=no" \
--set storage.configStore.enabled=true \
--set storage.logsStore.enabled=false \
--set storage.mode=postgres \
--set postgresql.enabled=true \
--set postgresql.auth.password=testpass
# config=postgres, logs=sqlite
test_template "config=postgres, logs=sqlite" \
--set storage.configStore.enabled=true \
--set storage.logsStore.enabled=true \
--set storage.mode=postgres \
--set postgresql.enabled=true \
--set postgresql.auth.password=testpass
# config=postgres, logs=postgres
test_template "config=postgres, logs=postgres" \
--set storage.configStore.enabled=true \
--set storage.logsStore.enabled=true \
--set storage.mode=postgres \
--set postgresql.enabled=true \
--set postgresql.auth.password=testpass
# 2. Vector Store Combinations (6 tests)
echo ""
echo -e "${CYAN}🗄️ 2/6 - Testing Vector Store Combinations (6 tests)...${NC}"
echo "--------------------------------------------------------"
# Weaviate
test_template "vectorStore=weaviate" \
--set vectorStore.enabled=true \
--set vectorStore.type=weaviate \
--set vectorStore.weaviate.enabled=true
# Redis
test_template "vectorStore=redis" \
--set vectorStore.enabled=true \
--set vectorStore.type=redis \
--set vectorStore.redis.enabled=true
# Qdrant
test_template "vectorStore=qdrant" \
--set vectorStore.enabled=true \
--set vectorStore.type=qdrant \
--set vectorStore.qdrant.enabled=true
# postgres + weaviate
test_template "postgres + weaviate" \
--set storage.mode=postgres \
--set postgresql.enabled=true \
--set postgresql.auth.password=testpass \
--set vectorStore.enabled=true \
--set vectorStore.type=weaviate \
--set vectorStore.weaviate.enabled=true
# postgres + qdrant
test_template "postgres + qdrant" \
--set storage.mode=postgres \
--set postgresql.enabled=true \
--set postgresql.auth.password=testpass \
--set vectorStore.enabled=true \
--set vectorStore.type=qdrant \
--set vectorStore.qdrant.enabled=true
# sqlite + qdrant
test_template "sqlite + qdrant" \
--set storage.mode=sqlite \
--set postgresql.enabled=false \
--set vectorStore.enabled=true \
--set vectorStore.type=qdrant \
--set vectorStore.qdrant.enabled=true
# 3. Special Configurations (7 tests)
echo ""
echo -e "${CYAN}⚙️ 3/6 - Testing Special Configurations (7 tests)...${NC}"
echo "-----------------------------------------------------"
# semantic cache: direct mode (dimension: 1, no provider/keys)
test_template "semanticCache: direct mode (dimension: 1)" \
--set bifrost.plugins.semanticCache.enabled=true \
--set bifrost.plugins.semanticCache.config.dimension=1 \
--set bifrost.plugins.semanticCache.config.ttl=30m \
--set vectorStore.enabled=true \
--set vectorStore.type=redis \
--set vectorStore.redis.enabled=true
# semantic cache: semantic mode (dimension > 1, requires provider/keys)
test_template "semanticCache: semantic mode (dimension: 1536)" \
--set bifrost.plugins.semanticCache.enabled=true \
--set bifrost.plugins.semanticCache.config.dimension=1536 \
--set bifrost.plugins.semanticCache.config.provider=openai \
--set 'bifrost.plugins.semanticCache.config.keys[0]=sk-test' \
--set vectorStore.enabled=true \
--set vectorStore.type=redis \
--set vectorStore.redis.enabled=true
# semantic cache: direct mode with redis + postgres
test_template "semanticCache: direct mode + postgres" \
--set bifrost.plugins.semanticCache.enabled=true \
--set bifrost.plugins.semanticCache.config.dimension=1 \
--set storage.mode=postgres \
--set postgresql.enabled=true \
--set postgresql.auth.password=testpass \
--set vectorStore.enabled=true \
--set vectorStore.type=redis \
--set vectorStore.redis.enabled=true
# sqlite + persistence + autoscaling (StatefulSet HPA)
test_template "sqlite + persistence + autoscaling (StatefulSet)" \
--set storage.mode=sqlite \
--set storage.persistence.enabled=true \
--set postgresql.enabled=false \
--set autoscaling.enabled=true \
--set autoscaling.minReplicas=2 \
--set autoscaling.maxReplicas=5
# postgres + autoscaling (Deployment HPA)
test_template "postgres + autoscaling (Deployment)" \
--set storage.mode=postgres \
--set postgresql.enabled=true \
--set postgresql.auth.password=testpass \
--set autoscaling.enabled=true \
--set autoscaling.minReplicas=2 \
--set autoscaling.maxReplicas=5
# ingress enabled
test_template "ingress enabled" \
--set ingress.enabled=true \
--set ingress.className=nginx \
--set 'ingress.hosts[0].host=bifrost.example.com' \
--set 'ingress.hosts[0].paths[0].path=/' \
--set 'ingress.hosts[0].paths[0].pathType=Prefix'
# full production-like config
test_template "production-like config" \
--set storage.mode=postgres \
--set postgresql.enabled=true \
--set postgresql.auth.password=testpass \
--set vectorStore.enabled=true \
--set vectorStore.type=qdrant \
--set vectorStore.qdrant.enabled=true \
--set autoscaling.enabled=true \
--set ingress.enabled=true \
--set ingress.className=nginx \
--set 'ingress.hosts[0].host=bifrost.example.com' \
--set 'ingress.hosts[0].paths[0].path=/' \
--set 'ingress.hosts[0].paths[0].pathType=Prefix'
# 4. New Property Rendering (Gap 1-8 tests)
echo ""
echo -e "${CYAN}🆕 4/6 - Testing New Property Rendering (Gap 1-8)...${NC}"
echo "-----------------------------------------------------"
# Gap 1+2: Client new properties
test_template "client: new properties (Gap 1+2)" \
--set bifrost.client.asyncJobResultTTL=300 \
--set 'bifrost.client.requiredHeaders[0]=X-Request-ID' \
--set 'bifrost.client.loggingHeaders[0]=X-Trace-ID' \
--set 'bifrost.client.allowedHeaders[0]=Authorization' \
--set bifrost.client.mcpAgentDepth=5 \
--set bifrost.client.mcpToolExecutionTimeout=30 \
--set bifrost.client.mcpCodeModeBindingLevel=server \
--set bifrost.client.mcpToolSyncInterval=60 \
--set bifrost.client.hideDeletedVirtualKeysInFilters=true
# Gap 3: OTel plugin with new fields
test_template "otel: headers + tls_ca_cert + insecure (Gap 3)" \
--set bifrost.plugins.otel.enabled=true \
--set bifrost.plugins.otel.config.collector_url=otel:4317 \
--set bifrost.plugins.otel.config.trace_type=genai_extension \
--set bifrost.plugins.otel.config.protocol=grpc \
--set 'bifrost.plugins.otel.config.headers.Authorization=Bearer token' \
--set bifrost.plugins.otel.config.tls_ca_cert=/certs/ca.pem \
--set bifrost.plugins.otel.config.insecure=true
# Gap 4: Governance plugin with new fields
test_template "governance: required_headers + is_enterprise (Gap 4)" \
--set bifrost.plugins.governance.enabled=true \
--set 'bifrost.plugins.governance.config.required_headers[0]=X-Team-ID' \
--set bifrost.plugins.governance.config.is_enterprise=true
# Gap 5: Governance modelConfigs + providers
test_template "governance: modelConfigs + providers (Gap 5)" \
--set 'bifrost.governance.modelConfigs[0].id=mc-1' \
--set 'bifrost.governance.modelConfigs[0].model_name=gpt-4o' \
--set 'bifrost.governance.providers[0].name=openai'
# Gap 6: MCP new fields
test_template "mcp: toolSyncInterval + codeModeBindingLevel (Gap 6)" \
--set bifrost.mcp.enabled=true \
--set bifrost.mcp.toolSyncInterval=10m \
--set bifrost.mcp.toolManagerConfig.codeModeBindingLevel=server \
--set 'bifrost.mcp.clientConfigs[0].name=test' \
--set 'bifrost.mcp.clientConfigs[0].connectionType=http' \
--set 'bifrost.mcp.clientConfigs[0].httpConfig.url=http://localhost:3000' \
--set 'bifrost.mcp.clientConfigs[0].clientId=client-1' \
--set 'bifrost.mcp.clientConfigs[0].isCodeModeClient=true' \
--set 'bifrost.mcp.clientConfigs[0].toolSyncInterval=5m'
# Gap 7: Cluster with region
test_template "cluster: region (Gap 7)" \
--set bifrost.cluster.enabled=true \
--set 'bifrost.cluster.peers[0]=peer-0:7946' \
--set bifrost.cluster.gossip.port=7946 \
--set bifrost.cluster.gossip.config.timeoutSeconds=10 \
--set bifrost.cluster.gossip.config.successThreshold=3 \
--set bifrost.cluster.gossip.config.failureThreshold=3 \
--set bifrost.cluster.region=us-east-1
# Gap 8: Combined production-like with all new fields
test_template "combined: all new Gap 1-8 fields" \
--set bifrost.client.asyncJobResultTTL=300 \
--set bifrost.client.mcpAgentDepth=5 \
--set bifrost.client.hideDeletedVirtualKeysInFilters=true \
--set bifrost.plugins.otel.enabled=true \
--set bifrost.plugins.otel.config.collector_url=otel:4317 \
--set bifrost.plugins.otel.config.trace_type=genai_extension \
--set bifrost.plugins.otel.config.protocol=grpc \
--set bifrost.plugins.otel.config.insecure=true \
--set bifrost.plugins.governance.enabled=true \
--set bifrost.plugins.governance.config.is_enterprise=true \
--set bifrost.cluster.enabled=true \
--set 'bifrost.cluster.peers[0]=peer-0:7946' \
--set bifrost.cluster.gossip.port=7946 \
--set bifrost.cluster.gossip.config.timeoutSeconds=10 \
--set bifrost.cluster.gossip.config.successThreshold=3 \
--set bifrost.cluster.gossip.config.failureThreshold=3 \
--set bifrost.cluster.region=us-west-2
# 5. Plugin Name Validation
echo ""
echo -e "${CYAN}🔌 5/6 - Validating Plugin Names Match Go Registry...${NC}"
echo "------------------------------------------------------"
# Verify semantic cache plugin renders with correct name ("semantic_cache", not "semantic_cache")
# Go registry: plugins/semantic_cache/main.go defines PluginName = "semantic_cache"
test_name="semanticCache plugin name matches Go registry (semantic_cache)"
if helm template bifrost ./helm-charts/bifrost \
--set image.tag=v1.0.0 \
--set bifrost.plugins.semanticCache.enabled=true \
--set bifrost.plugins.semanticCache.config.dimension=1536 \
--set bifrost.plugins.semanticCache.config.provider=openai \
--set 'bifrost.plugins.semanticCache.config.keys[0]=sk-test' \
--set vectorStore.enabled=true \
--set vectorStore.type=redis \
--set vectorStore.redis.enabled=true \
> /tmp/helm-template-output.yaml 2>&1; then
if grep -Eq '"name"[[:space:]]*:[[:space:]]*"semantic_cache"' /tmp/helm-template-output.yaml; then
report_result "$test_name" 0
else
report_result "$test_name" 1
fi
else
report_result "$test_name" 1
echo -e "${YELLOW} Error output:${NC}"
head -10 /tmp/helm-template-output.yaml | sed 's/^/ /'
fi
# 6. Custom Plugin Placement and Order Rendering
echo ""
echo -e "${CYAN}🔧 6/6 - Validating Custom Plugin placement and order Rendering...${NC}"
echo "-------------------------------------------------------------------"
# Test custom plugin renders successfully with placement and order
test_template "custom plugin with placement and order" \
--set 'bifrost.plugins.custom[0].name=my-plugin' \
--set 'bifrost.plugins.custom[0].enabled=true' \
--set 'bifrost.plugins.custom[0].path=/plugins/my-plugin.so' \
--set 'bifrost.plugins.custom[0].placement=pre_builtin' \
--set 'bifrost.plugins.custom[0].order=2'
# Verify placement appears in rendered output
test_name="custom plugin rendered JSON contains placement field"
if helm template bifrost ./helm-charts/bifrost \
--set image.tag=v1.0.0 \
--set 'bifrost.plugins.custom[0].name=my-plugin' \
--set 'bifrost.plugins.custom[0].enabled=true' \
--set 'bifrost.plugins.custom[0].path=/plugins/my-plugin.so' \
--set 'bifrost.plugins.custom[0].placement=pre_builtin' \
--set 'bifrost.plugins.custom[0].order=2' \
> /tmp/helm-template-output.yaml 2>&1; then
if grep -Eq '"placement"[[:space:]]*:[[:space:]]*"pre_builtin"' /tmp/helm-template-output.yaml; then
report_result "$test_name" 0
else
report_result "$test_name" 1
echo -e "${YELLOW} placement field not found in rendered output${NC}"
fi
else
report_result "$test_name" 1
echo -e "${YELLOW} Error output:${NC}"
head -10 /tmp/helm-template-output.yaml | sed 's/^/ /'
fi
# Verify order appears in rendered output
test_name="custom plugin rendered JSON contains order field"
if helm template bifrost ./helm-charts/bifrost \
--set image.tag=v1.0.0 \
--set 'bifrost.plugins.custom[0].name=my-plugin' \
--set 'bifrost.plugins.custom[0].enabled=true' \
--set 'bifrost.plugins.custom[0].path=/plugins/my-plugin.so' \
--set 'bifrost.plugins.custom[0].placement=pre_builtin' \
--set 'bifrost.plugins.custom[0].order=2' \
> /tmp/helm-template-output.yaml 2>&1; then
if grep -Eq '"order"[[:space:]]*:[[:space:]]*2' /tmp/helm-template-output.yaml; then
report_result "$test_name" 0
else
report_result "$test_name" 1
echo -e "${YELLOW} order field not found in rendered output${NC}"
fi
else
report_result "$test_name" 1
echo -e "${YELLOW} Error output:${NC}"
head -10 /tmp/helm-template-output.yaml | sed 's/^/ /'
fi
# Cleanup
rm -f /tmp/helm-template-output.yaml
# Final Summary
echo ""
echo "======================================"
echo "🏁 Helm Template Validation Complete!"
echo "======================================"
echo -e "${GREEN}Passed: $TESTS_PASSED${NC}"
echo -e "${RED}Failed: $TESTS_FAILED${NC}"
echo ""
if [ "$TESTS_FAILED" -gt 0 ]; then
echo -e "${RED}❌ Some template validations failed. Please review the output above.${NC}"
exit 1
else
echo -e "${GREEN}✅ All template validations passed successfully!${NC}"
exit 0
fi

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
# Validate that Go config types in transports/bifrost-http/lib/config.go
# stay in sync (fields + enum values) with transports/config.schema.json.
# Walks the type graph recursively via go/types rather than regex-parsing source.
if command -v readlink >/dev/null 2>&1 && readlink -f "$0" >/dev/null 2>&1; then
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
else
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
fi
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
TOOL_DIR="$SCRIPT_DIR/schemasync"
cd "$REPO_ROOT"
if ! command -v go >/dev/null 2>&1; then
echo "❌ go toolchain required for schema-sync validation"
exit 2
fi
# Ensure go.work exists at the repo root. schemasync's packages.Load needs
# it to resolve bifrost's local modules against each other. On fresh CI
# runners go.work is not checked in, so we provision it here inline.
# Sibling scripts (test-bifrost-http.sh etc.) call setup-go-workspace.sh
# via `source`, but that relies on the `return` builtin which has
# platform-dependent edge cases under `set -e`; we instead do the same
# work inline so this wrapper is self-contained.
if [ ! -f "$REPO_ROOT/go.work" ]; then
echo "🔧 Setting up Go workspace (go.work not found)..."
(
cd "$REPO_ROOT"
go work init
for mod in ./core ./framework \
./plugins/compat ./plugins/governance ./plugins/jsonparser \
./plugins/logging ./plugins/maxim ./plugins/mocker \
./plugins/otel ./plugins/prompts ./plugins/semanticcache \
./plugins/telemetry \
./transports ./cli; do
if [ -f "$REPO_ROOT/$mod/go.mod" ]; then
go work use "$mod"
fi
done
)
echo "✅ Go workspace initialized at $REPO_ROOT/go.work"
else
echo "🔍 Go workspace already exists at $REPO_ROOT/go.work, skipping initialization"
fi
echo "🔍 Validating Go ↔ config.schema.json sync (recursive, AST-based)"
echo "=================================================================="
# The schemasync tool is its own module (separate go.mod). Build it with
# GOWORK=off so the tool's deps (golang.org/x/tools) resolve against its
# own go.mod, not the repo's go.work. At runtime the tool itself sets
# GOWORK=<repo-root>/go.work when loading bifrost packages.
(cd "$TOOL_DIR" && GOWORK=off go build -o /tmp/schemasync .)
/tmp/schemasync \
--schema "$REPO_ROOT/transports/config.schema.json" \
--pkg-root "$REPO_ROOT" \
--helm-values "$REPO_ROOT/helm-charts/bifrost/values.schema.json" \
--helm-helpers "$REPO_ROOT/helm-charts/bifrost/templates/_helpers.tpl"

View File

@@ -0,0 +1,73 @@
#!/bin/bash
# Script to verify if bifrost-http was successfully released
# This ensures Docker images are only built after a successful bifrost-http release
# Exits with code 0 if release is verified or not needed, exits with code 78 to skip if release failed
set -e
VERSION=$1
RELEASE_NEEDED=$2
if [ -z "$VERSION" ]; then
echo "❌ Error: Version not provided"
exit 1
fi
# If release was not needed, skip verification
if [ "$RELEASE_NEEDED" = "false" ]; then
echo " Bifrost-http release was not needed, skipping verification"
echo " Docker images will be built with existing version"
exit 0
fi
echo "🔍 Verifying bifrost-http release v${VERSION}..."
# Check if the git tag exists
if ! git rev-parse "transports/bifrost-http/v${VERSION}" >/dev/null 2>&1; then
echo "⚠️ Git tag transports/bifrost-http/v${VERSION} not found"
echo " Bifrost-http release did not complete successfully"
echo " Skipping Docker image build..."
exit 78 # Exit code 78 will be used to skip the job
fi
echo "✅ Git tag found: transports/bifrost-http/v${VERSION}"
# Check if the GitHub release exists
if [ -n "$GH_TOKEN" ]; then
echo "🔍 Checking GitHub release..."
if gh release view "transports/bifrost-http/v${VERSION}" >/dev/null 2>&1; then
echo "✅ GitHub release found for transports/bifrost-http/v${VERSION}"
else
echo "⚠️ GitHub release for transports/bifrost-http/v${VERSION} not found"
echo " Bifrost-http release did not complete successfully"
echo " Skipping Docker image build..."
exit 78 # Exit code 78 will be used to skip the job
fi
else
echo "⚠️ Warning: GH_TOKEN not set, skipping GitHub release check"
fi
# Check if dist binaries exist for the version
echo "🔍 Checking if release binaries exist..."
BINARY_FOUND=false
# Check for common binary paths
for arch in "darwin/amd64" "darwin/arm64" "linux/amd64"; do
BINARY_PATH="dist/${arch}/bifrost-http"
if [ -f "$BINARY_PATH" ]; then
echo "✅ Found binary: $BINARY_PATH"
BINARY_FOUND=true
break
fi
done
if [ "$BINARY_FOUND" = false ]; then
echo "⚠️ Warning: No release binaries found in dist/, but continuing..."
echo " This might be expected if binaries are uploaded to external storage"
fi
echo ""
echo "✅ Verification complete: bifrost-http v${VERSION} was successfully released"
echo " Proceeding with Docker image build..."

159
.github/workflows/snyk.yml vendored Normal file
View File

@@ -0,0 +1,159 @@
name: Snyk checks
on:
push:
branches: [main, master, "**/*"]
pull_request:
branches: ["**/*"]
workflow_dispatch:
permissions:
contents: read
security-events: write
jobs:
snyk-open-source:
name: Snyk Open Source (deps)
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
api.snyk.io:443
downloads.snyk.io:443
files.pythonhosted.org:443
fonts.googleapis.com:443
fonts.gstatic.com:443
github.com:443
iojs.org:443
nodejs.org:443
packages.microsoft.com:443
proxy.golang.org:443
raw.githubusercontent.com:443
registry.npmjs.org:443
release-assets.githubusercontent.com:443
releases.astral.sh:443
static.snyk.io:443
storage.googleapis.com:443
sum.golang.org:443
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node (for UI)
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "25"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
version: "0.11.0"
python-version: "3.11"
- name: Sync Python dependencies (integrations)
working-directory: tests/integrations/python
run: uv sync --frozen
- name: Setup Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: "1.26.2"
- name: Setup Go workspace
run: make setup-workspace
- name: Build
run: make build LOCAL=1
- name: Install Snyk CLI
uses: maximhq/snyk-actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0
with:
snyk-version: v1.1303.2
- name: Snyk test (all projects)
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
run: snyk test --all-projects --detection-depth=4 --exclude=examples,tests --sarif-file-output=snyk.sarif || true
- name: Upload SARIF
if: always() && hashFiles('snyk.sarif') != ''
uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
with:
sarif_file: snyk.sarif
snyk-code:
name: Snyk Code (SAST)
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
api.snyk.io:443
deeproxy.snyk.io:443
downloads.snyk.io:443
files.pythonhosted.org:443
fonts.googleapis.com:443
fonts.gstatic.com:443
github.com:443
iojs.org:443
nodejs.org:443
packages.microsoft.com:443
proxy.golang.org:443
raw.githubusercontent.com:443
registry.npmjs.org:443
release-assets.githubusercontent.com:443
releases.astral.sh:443
storage.googleapis.com:443
sum.golang.org:443
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node (for UI)
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "25"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
version: "0.11.0"
python-version: "3.11"
- name: Sync Python dependencies (integrations)
working-directory: tests/integrations/python
run: uv sync --frozen
- name: Setup Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: "1.26.2"
- name: Setup Go workspace
run: make setup-workspace
- name: Build
run: make build LOCAL=1
- name: Install Snyk CLI
uses: maximhq/snyk-actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0
with:
snyk-version: v1.1303.2
- name: Snyk Code test
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
run: snyk code test --sarif-file-output=snyk-code.sarif || true
- name: Upload SARIF
if: always() && hashFiles('snyk-code.sarif') != ''
uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
with:
sarif_file: snyk-code.sarif

173
.gitignore vendored Normal file
View File

@@ -0,0 +1,173 @@
.env
.vscode
.DS_Store
*_creds*
**/venv/
**/__pycache__/**
private.*
.venv
bifrost-data
test-coverage-local.sh
# Enterprise
ui/app/enterprise
ui/components/enterprise
# Temporary directories
**/temp/
/transports/ui
/transports/bifrost-http/lib/ui
/transports/bifrost-http/ui/
transports/bifrost-http/logs/
transports/bifrost-http/tmp/
node_modules
/dist
**/dist
**/tmp/
/temp*/
!examples/mcps/temperature/
tmp/
tmp-*
.tmp
private
**/bin
build/
target/
# Go workspaces (local only)
go.work
go.work.sum
# Generated schema copy (created by go generate)
transports/schema/config.schema.json
# Sqlite DBs
*.db
*.db-shm
*.db-wal
# Test reports
test-reports
tests/e2e/api/newman-reports
# AI Agent specific
settings.local.json
.codex/
.cursor/
.claude/*
!.claude/skills/
# Python specific
**/__pycache__/**
**/venv/
**/.venv/
**/.pytest_cache/
**/.coverage/
**/.pytest_cache/
# Nix specific
.direnv
.nix-store
# MCP servers
examples/mcps/**/bin/
examples/mcps/http-no-ping-server/http-server
examples/mcps/test-tools-server/dist/
# Dependencies
node_modules/
# Test results
test-results/
playwright-report/
blob-report/
# Screenshots and videos
screenshots/
videos/
# Coverage
coverage/
# Build output
dist/
# IDE
.idea/
.vscode/
# OS
.DS_Store
Thumbs.db
# Debug logs
*.log
npm-debug.log*
# Playwright
playwright/.cache/
# Terraform
.terraform/
.terraform.lock.hcl
terraform.tfstate
terraform.tfstate.backup
*.tfvars
!*.tfvars.example
# Bifrost benchmarking
bifrost-benchmarking
# Tests
:memory:
# Generated test TLS certs (created by tests/docker-compose.yml redis-certs-init)
tests/redis-certs/
# dependencies
ui/node_modules
ui/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
ui/coverage
# next.js
ui/.next/
ui/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# auto-generated TanStack Router route tree
ui/app/routeTree.gen.ts
.tanstack
.next

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.12.0

31
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,31 @@
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.16.3
hooks:
- id: gitleaks
- repo: https://github.com/golangci/golangci-lint
rev: v2.9.0
hooks:
- id: golangci-lint
- repo: https://github.com/jumanjihouse/pre-commit-hooks
rev: 3.0.0
hooks:
- id: shellcheck
- repo: local
hooks:
- id: eslint
name: eslint (ui)
language: system
entry: ui/node_modules/.bin/eslint
args: [-c, ui/eslint.config.mjs, --max-warnings=0, --fix]
files: ^ui/.*\.(mjs|cjs|jsx|tsx?|js)$
exclude: ^ui/node_modules/
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/pylint-dev/pylint
rev: v2.17.2
hooks:
- id: pylint

7
.snyk Normal file
View File

@@ -0,0 +1,7 @@
# Snyk (https://snyk.io) policy file
# Manages vulnerability ignores and patches for this repository.
version: v1.25.0
ignore: {
"tests"
}
patch: {}

930
AGENTS.md Normal file
View File

@@ -0,0 +1,930 @@
# AGENTS.md — Bifrost AI Gateway
> Context for AI agents (Claude Code, Copilot, Cursor, etc.) working on this codebase. Read this fully before making changes.
## What is Bifrost?
Bifrost is a high-performance AI gateway that unifies 20+ LLM providers behind a single OpenAI-compatible API with ~11µs overhead at 5,000 RPS. It also serves as an MCP (Model Context Protocol) gateway, turning static chat models into tool-calling agents.
GitHub: `maximhq/bifrost`
---
## Repository Layout
```
bifrost/
├── core/ # Go core library — the engine
│ ├── bifrost.go # Main struct, request queuing, provider lifecycle (~3.4K lines)
│ ├── inference.go # Inference routing, fallbacks, streaming dispatch (~1.9K lines)
│ ├── mcp.go # MCP integration entry point
│ ├── schemas/ # ALL shared Go types — 41 files
│ │ ├── bifrost.go # BifrostConfig, ModelProvider enum, RequestType enum, context keys
│ │ ├── provider.go # Provider interface (30+ methods), NetworkConfig, ProviderConfig
│ │ ├── plugin.go # LLMPlugin, MCPPlugin, HTTPTransportPlugin, ObservabilityPlugin
│ │ ├── context.go # BifrostContext (custom context.Context with mutable values)
│ │ ├── chatcompletions.go # Chat completion request/response types
│ │ ├── responses.go # OpenAI Responses API types
│ │ ├── embedding.go # Embedding types
│ │ ├── images.go # Image generation types
│ │ ├── batch.go # Batch operation types
│ │ ├── files.go # File management types
│ │ ├── mcp.go # MCP types
│ │ ├── trace.go # Tracer interface
│ │ └── logger.go # Logger interface
│ ├── providers/ # 20+ provider implementations
│ │ ├── openai/ # Reference implementation (largest, most complete)
│ │ ├── anthropic/ # Non-OpenAI-compatible example
│ │ ├── bedrock/ # AWS event-stream protocol
│ │ ├── gemini/ # Google-specific API shape
│ │ ├── groq/ # OpenAI-compatible (minimal, delegates to openai/)
│ │ └── utils/ # Shared: HTTP client, SSE parsing, error handling, scanner pool
│ ├── pool/ # Generic Pool[T] — dual-mode (prod: sync.Pool, debug: full tracking)
│ │ ├── pool_prod.go # Zero-overhead sync.Pool wrapper (default build)
│ │ └── pool_debug.go # Double-release/use-after-release/leak detection (-tags pooldebug)
│ ├── mcp/ # MCP protocol implementation
│ │ ├── agent.go # Agent orchestration loop (multi-turn tool calling)
│ │ ├── clientmanager.go # MCP client lifecycle management
│ │ ├── toolmanager.go # Tool registration, discovery, filtering
│ │ ├── healthmonitor.go # Client health monitoring
│ │ └── codemode/starlark/ # Starlark sandbox for code-mode execution
│ └── internal/
│ ├── llmtests/ # LLM integration test infra (48 files, scenario-based)
│ └── mcptests/ # MCP/Agent test infra (40+ files, mock-based)
├── framework/ # Data persistence, streaming, ecosystem utilities
│ ├── configstore/ # Config storage backends (file, postgres)
│ ├── logstore/ # Log storage backends (file, postgres)
│ ├── vectorstore/ # Vector storage (Weaviate, Qdrant, Redis, Pinecone)
│ ├── streaming/ # Streaming accumulator, delta copying, response marshaling
│ │ ├── accumulator.go # Chunk accumulation into full response (~24KB)
│ │ ├── chat.go # Chat stream handling (~17KB)
│ │ └── responses.go # Response stream marshaling (~35KB)
│ ├── modelcatalog/ # Model metadata registry
│ ├── tracing/ # Distributed tracing helpers
│ └── encrypt/ # Encryption utilities
├── transports/
│ ├── config.schema.json # JSON Schema — THE source of truth for config.json (~2700 lines)
│ └── bifrost-http/ # HTTP gateway transport
│ ├── server/ # Server lifecycle, route registration
│ ├── handlers/ # 27 HTTP endpoint handlers
│ │ ├── inference.go # Chat/text completions, responses API (~109KB)
│ │ ├── mcpinference.go # MCP tool execution
│ │ ├── governance.go # Virtual keys, teams, customers, budgets (~100KB)
│ │ ├── providers.go # Provider CRUD, key management
│ │ ├── mcp.go # MCP client registry management
│ │ ├── logging.go # Log queries, stats, histograms
│ │ ├── config.go # System configuration
│ │ ├── plugins.go # Plugin CRUD
│ │ ├── cache.go # Cache management
│ │ ├── session.go # Auth/session management
│ │ ├── health.go # Health checks
│ │ ├── mcpserver.go # MCP server (SSE/streamable HTTP)
│ │ ├── websocket.go # WebSocket handler
│ │ ├── devpprof.go # Pool debug profiler endpoint (~23KB)
│ │ └── middlewares.go # Middleware definitions
│ ├── lib/ # ChainMiddlewares, config, context conversion
│ └── integrations/ # SDK compatibility layers
│ ├── openai.go # OpenAI SDK drop-in compatibility
│ ├── anthropic.go # Anthropic SDK compatibility
│ ├── bedrock.go # AWS Bedrock SDK compatibility
│ ├── genai.go # Google GenAI SDK compatibility
│ ├── langchain.go # LangChain compatibility
│ ├── litellm.go # LiteLLM compatibility
│ └── pydanticai.go # PydanticAI compatibility
├── plugins/ # Go plugins — each has own go.mod
│ ├── governance/ # Budget, rate limiting, virtual keys, routing, RBAC
│ ├── telemetry/ # Prometheus metrics, push gateway
│ ├── logging/ # Request/response audit logging
│ ├── semanticcache/ # Semantic response caching via vector store
│ ├── otel/ # OpenTelemetry tracing
│ ├── mocker/ # Mock responses for testing
│ ├── jsonparser/ # JSON extraction utilities
│ ├── maxim/ # Maxim observability
│ └── compat/ # LiteLLM SDK compatibility (HTTP transport)
├── ui/ # React + vite web interface
│ ├── app/workspace/ # Feature pages (20+ workspace sections)
│ ├── components/ # Shared React components
│ └── lib/ # Constants, utilities, types
├── tests/e2e/ # Playwright E2E tests
│ ├── core/ # Fixtures, page objects, helpers, API actions
│ └── features/ # Per-feature test suites
├── docs/ # Mintlify MDX documentation
│ ├── docs.json # Navigation config
│ ├── media/ # Screenshots (ui-*.png naming convention)
│ └── (architecture|features|providers|mcp|plugins|enterprise|...)
├── .claude/skills/ # Claude Code skill definitions (4 skills)
├── go.work # Go workspace — requires Go 1.26.1
├── Makefile # Build, test, dev commands (1300+ lines)
└── terraform/ # Infrastructure as Code
```
---
## Go Workspace
Bifrost is a **multi-module Go workspace**. Each module has its own `go.mod`:
```
go.work
├── core/go.mod # github.com/maximhq/bifrost/core
├── framework/go.mod # github.com/maximhq/bifrost/framework
├── transports/go.mod # github.com/maximhq/bifrost/transports
└── plugins/*/go.mod # 9 plugin modules (governance, telemetry, logging, etc.)
```
**Rules:**
- Run `go mod tidy` in the **specific module directory**, not the root
- Cross-module imports resolve via workspace locally, but need explicit `require` in `go.mod` for releases
- The workspace requires **Go 1.26.1** (`go.work` directive)
---
## Build, Test & Dev Commands
```bash
# Development
make dev # Full local dev (UI + API with hot reload via air)
make build # Build bifrost-http binary
# Core tests (provider integration tests — hit live APIs)
make test-core # All providers
make test-core PROVIDER=openai # Specific provider
make test-core PROVIDER=openai TESTCASE=TestSimpleChat # Specific test
make test-core PATTERN=TestStreaming # Tests matching pattern
make test-core DEBUG=1 # With Delve debugger on :2345
# MCP/Agent tests (mock-based, no live APIs)
make test-mcp # All MCP tests
make test-mcp TESTCASE=TestAgentLoop # Specific test
make test-mcp TYPE=agent # By category (agent|tool|connection|codemode)
# Plugin tests
make test-plugins # All plugins
make test-governance # Governance plugin specifically
# Integration tests (SDK compatibility)
make test-integrations-py # Python SDK tests
make test-integrations-ts # TypeScript SDK tests
# E2E tests (Playwright, requires running dev server)
make run-e2e # All E2E tests
make run-e2e FLOW=providers # Specific feature
# Code quality
make lint # Linting
make fmt # Format code
```
---
## Architecture
### Request Flow
```
Client HTTP Request
→ FastHTTP Transport (parsing, validation ~2µs)
→ SDK Integration Layer (OpenAI/Anthropic/Bedrock format → Bifrost format)
→ Middleware Chain (lib.ChainMiddlewares, applied per-route)
→ HTTPTransportPreHook (HTTP-level plugins, can short-circuit)
→ PreLLMHook Pipeline (auth, rate-limit, cache check — registration order)
→ MCP Tool Discovery & Injection (if tool_choice present)
→ Provider Queue (channel-based, per-provider isolation)
→ Worker picks up request
→ Key Selection (~10ns weighted random)
→ Provider API Call (fasthttp client, connection pooling)
→ Response / SSE Stream
→ PostLLMHook Pipeline (reverse order of PreLLMHooks)
→ Tool Execution Loop (if tool_calls in response, MCP agent loop)
→ HTTPTransportPostHook (reverse order)
→ Response Serialization
→ HTTP Response to Client
```
### Design Principles
- **Provider isolation**: Each provider has its own worker pool and queue. One provider going down doesn't cascade to others.
- **Channel-based async**: Request routing uses Go channels (`chan *ChannelMessage`), not mutexes. The `ProviderQueue` struct manages channel lifecycle with atomic flags.
- **Object pooling everywhere**: `sync.Pool` wrappers reduce GC pressure. Pools exist for: channel messages, response channels, error channels, stream channels, plugin pipelines, MCP requests, HTTP request/response objects, scanner buffers.
- **Plugin pipeline symmetry**: Pre-hooks execute in registration order, post-hooks in **reverse** order (LIFO). For every pre-hook executed, the corresponding post-hook is guaranteed to run.
- **Streaming**: SSE chunks flow through `chan chan *schemas.BifrostStreamChunk`. Accumulated into full response for post-hooks via `framework/streaming/accumulator.go`.
### BifrostContext — Custom Context
`BifrostContext` (`core/schemas/context.go`) is a custom `context.Context` with **thread-safe mutable values**. Unlike standard Go contexts, values can be set after creation:
```go
ctx := schemas.NewBifrostContext(parent, deadline)
ctx.SetValue(key, value) // Thread-safe, uses RWMutex
ctx.WithValue(key, value) // Chainable variant
```
**Reserved context keys** (set by Bifrost internals — DO NOT set manually):
- `BifrostContextKeySelectedKeyID/Name` — Set by governance plugin
- `BifrostContextKeyGovernance*` — Set by governance plugin
- `BifrostContextKeyNumberOfRetries`, `BifrostContextKeyFallbackIndex` — Set by retry/fallback logic
- `BifrostContextKeyStreamEndIndicator` — Set by streaming infrastructure
- `BifrostContextKeyTrace*`, `BifrostContextKeySpan*` — Set by tracing middleware
**User-settable keys** (plugins and handlers can set these):
- `BifrostContextKeyVirtualKey` (`x-bf-vk`) — Virtual key for governance
- `BifrostContextKeyAPIKeyName` (`x-bf-api-key`) — Explicit key selection by name
- `BifrostContextKeyAPIKeyID` (`x-bf-api-key-id`) — Explicit key selection by ID (takes priority over name)
- `BifrostContextKeyRequestID` — Request ID
- `BifrostContextKeyExtraHeaders` — Extra headers to forward to provider
- `BifrostContextKeyURLPath` — Custom URL path for provider
- `BifrostContextKeySkipKeySelection` — Skip key selection (pass empty key)
- `BifrostContextKeyUseRawRequestBody` — Send raw body directly to provider
**Gotcha**: `BlockRestrictedWrites()` silently drops writes to reserved keys. This prevents plugins from accidentally overwriting internal state.
---
## Core Patterns
### Provider Implementation
There are **two categories** of providers:
**Category 1: Non-OpenAI-compatible** (Anthropic, Bedrock, Gemini, Cohere, HuggingFace, Replicate, ElevenLabs):
```
core/providers/<name>/
├── <name>.go # Controller: constructor, interface methods, HTTP orchestration
├── <name>_test.go # Tests
├── types.go # ALL provider-specific structs (PascalCase prefixed with provider name)
├── utils.go # Constants, base URLs, helpers (camelCase for unexported)
├── errors.go # Error parsing: provider HTTP error → *schemas.BifrostError
├── chat.go # Chat request/response converters
├── embedding.go # Embedding converters (if supported)
├── images.go # Image generation (if supported)
├── speech.go # TTS/STT (if supported)
└── responses.go # Responses API + streaming converters
```
**Category 2: OpenAI-compatible** (Groq, Cerebras, Ollama, Perplexity, OpenRouter, Parasail, Nebius, xAI, SGL):
```
core/providers/<name>/
├── <name>.go # Minimal — constructor + delegates to openai.HandleOpenAI* functions
└── <name>_test.go # Tests
```
**Converter function naming convention:**
- `To<ProviderName><Feature>Request()` — Bifrost schema → Provider API format
- `ToBifrost<Feature>Response()` — Provider API format → Bifrost schema
- These must be **pure transformation functions** — no HTTP calls, no logging, no side effects
**Provider constructor pattern:**
```go
func NewProvider(config schemas.ProviderConfig) (*Provider, error) {
// Validate config, set up fasthttp.Client with connection pooling
client := &fasthttp.Client{
MaxConnsPerHost: config.NetworkConfig.MaxConnsPerHost, // configurable, default 5000
MaxIdleConnDuration: 30 * time.Second,
}
// After ConfigureProxy/ConfigureDialer/ConfigureTLS, build a sibling client
// for streaming. BuildStreamingClient zeros ReadTimeout/WriteTimeout/MaxConnDuration
// so streams aren't killed by fasthttp's whole-response deadline; per-chunk idle
// is enforced at the app layer via NewIdleTimeoutReader.
streamingClient := providerUtils.BuildStreamingClient(client)
return &Provider{client: client, streamingClient: streamingClient, ...}, nil
}
```
**Streaming vs unary client:** Every provider holds two clients — `client` for unary requests (`ReadTimeout=30s` bounds the whole response) and `streamingClient` for SSE / EventStream / chunked paths (`ReadTimeout=0`; the per-chunk `NewIdleTimeoutReader` is the only governor). Pass `provider.streamingClient` to every `Handle*Streaming` / `Handle*StreamRequest` helper and to direct `Do` calls inside `*Stream` methods. For new providers, apply the same pattern — missing the switch means streams get killed at 30s.
**Note:** Bedrock uses `net/http` (not fasthttp) with HTTP/2 support. Its `http.Transport` is configured with `ForceAttemptHTTP2: true` and `MaxConnsPerHost` from `NetworkConfig` to allow multiple HTTP/2 connections when the server's per-connection stream limit (100 for AWS Bedrock) is reached. Use `providerUtils.BuildStreamingHTTPClient(client)` to derive the streaming variant — it shares the base `Transport` (safe for concurrent reuse) but clears `Client.Timeout`.
### The Provider Interface
`core/schemas/provider.go` defines the `Provider` interface with **30+ methods**. Every provider must implement all of them (returning "not supported" for unsupported operations). The interface covers:
- `ListModels`, `ChatCompletion`, `ChatCompletionStream`
- `Responses`, `ResponsesStream` (OpenAI Responses API)
- `TextCompletion`, `TextCompletionStream`
- `Embedding`, `Speech`, `SpeechStream`, `Transcription`, `TranscriptionStream`
- `ImageGeneration`, `ImageGenerationStream`, `ImageEdit`, `ImageEditStream`, `ImageVariation`
- `CountTokens`
- `Batch*` (Create, List, Retrieve, Cancel, Results)
- `File*` (Upload, List, Retrieve, Delete, Content)
- `Container*` and `ContainerFile*` (Create, List, Retrieve, Delete, Content)
**Streaming methods** receive a `PostHookRunner` callback and return `chan *BifrostStreamChunk`:
```go
ChatCompletionStream(ctx *BifrostContext, postHookRunner PostHookRunner, key Key, request *BifrostChatRequest) (chan *BifrostStreamChunk, *BifrostError)
```
### Error Handling
Each provider has `errors.go` with an `ErrorConverter` function:
```go
type ErrorConverter func(resp *fasthttp.Response, requestType schemas.RequestType, providerName schemas.ModelProvider, model string) *schemas.BifrostError
```
The shared utility `providerUtils.HandleProviderAPIError()` handles common HTTP error parsing. Provider-specific parsers add extra field mapping. Errors always carry metadata:
```go
bifrostErr.ExtraFields.Provider = providerName
bifrostErr.ExtraFields.ModelRequested = model
bifrostErr.ExtraFields.RequestType = requestType
```
### Plugin System
Four plugin interfaces exist:
| Interface | Hook Methods | When Called |
|-----------|-------------|------------|
| `LLMPlugin` | `PreLLMHook`, `PostLLMHook` | Every LLM request (SDK + HTTP) |
| `MCPPlugin` | `PreMCPHook`, `PostMCPHook` | Every MCP tool execution |
| `HTTPTransportPlugin` | `HTTPTransportPreHook`, `HTTPTransportPostHook`, `HTTPTransportStreamChunkHook` | HTTP gateway only (not Go SDK) |
| `ObservabilityPlugin` | `Inject(ctx, trace)` | Async, after response written to wire |
**Key plugin behaviors:**
- Plugin errors are **logged as warnings**, never returned to the caller
- Pre-hooks can **short-circuit** by returning `*LLMPluginShortCircuit` (cache hit, auth failure, rate limit)
- Post-hooks receive both response and error — either can be nil. Plugins can **recover from errors** (set error to nil, provide response) or **invalidate responses** (set response to nil, provide error)
- `BifrostError.AllowFallbacks` controls whether fallback providers are tried: `nil` or `&true` = allow, `&false` = block
- `HTTPTransportStreamChunkHook` is called **per-chunk** during streaming — can modify, skip, or abort the stream
### Pool System
`core/pool/` provides `Pool[T]` with two build modes:
```go
// Production (default): zero-overhead sync.Pool wrapper
// Debug (-tags pooldebug): tracks double-release, use-after-release, leaks with stack traces
p := pool.New[MyType]("descriptive-name", func() *MyType { return &MyType{} })
obj := p.Get()
// ... use obj ...
// MUST reset ALL fields before Put — pool does not auto-reset
p.Put(obj)
```
**Acquire/Release pattern** for types with complex reset logic (used in `schemas/plugin.go`):
```go
req := schemas.AcquireHTTPRequest() // Get from pool, pre-allocated maps
defer schemas.ReleaseHTTPRequest(req) // Clears all maps and fields, returns to pool
```
### HTTP Transport Layer
**Handler pattern:** Handlers are structs with injected dependencies:
```go
type CompletionHandler struct {
client *bifrost.Bifrost
handlerStore lib.HandlerStore
config *lib.Config
}
```
**Route registration:** Each handler implements `RegisterRoutes(router, middlewares...)` — routes get middleware chains applied per-route via `lib.ChainMiddlewares()`.
**SDK integration layers** (`transports/bifrost-http/integrations/`) provide request/response converters between provider-native SDK formats and Bifrost's internal format. This enables drop-in replacement of OpenAI SDK, Anthropic SDK, AWS Bedrock SDK, Google GenAI SDK, LangChain, and LiteLLM.
---
## Gotchas
### 1. Always Reset Pooled Objects Before Put
Every pooled object must have **all** fields zeroed before `pool.Put()`. Stale data leaks between requests. The debug build catches double-release and use-after-release but **not** missing resets.
```go
// WRONG — stale data from previous request leaks to next user
pool.Put(msg)
// RIGHT
msg.Response = nil
msg.Error = nil
msg.Context = nil
msg.ResponseStream = nil
pool.Put(msg)
```
### 2. Channel Lifecycle — ProviderQueue Pattern
`ProviderQueue` uses atomic flags and `sync.Once` to prevent "send on closed channel" panics:
```go
type ProviderQueue struct {
queue chan *ChannelMessage
done chan struct{}
closing uint32 // atomic: 0=open, 1=closing
signalOnce sync.Once // ensure signal fires only once
closeOnce sync.Once // ensure close fires only once
}
```
Always check the atomic closing flag before sending. Never close a channel without this pattern.
### 3. NetworkConfig Duration Serialization
`RetryBackoffInitial` and `RetryBackoffMax` are `time.Duration` (nanoseconds) in Go but **milliseconds** (integers) in JSON. Custom `MarshalJSON`/`UnmarshalJSON` handles conversion. If adding new duration fields to any config struct, follow this pattern exactly.
### 4. ExtraHeaders — Defensive Map Copy
`NetworkConfig.ExtraHeaders` is deep-copied in `CheckAndSetDefaults()` to prevent data races between concurrent requests. Apply the same `maps.Copy()` pattern to any new map fields in config structs.
### 5. Provider Interface Has 30+ Methods
Adding a new operation type requires changes across the entire codebase:
1. Add method to `Provider` interface in `core/schemas/provider.go`
2. Implement in **all** 20+ providers (most return "not supported")
3. Add `RequestType` constant in `core/schemas/bifrost.go`
4. Add to `AllowedRequests` struct and `IsOperationAllowed()` switch
5. Add handler endpoint in `transports/bifrost-http/handlers/`
6. Wire up in `core/bifrost.go` and `core/inference.go`
### 6. OpenAI Provider Changes Cascade to 9+ Providers
Groq, Cerebras, Ollama, Perplexity, OpenRouter, Parasail, Nebius, xAI, and SGL all delegate to `openai.HandleOpenAI*` functions. **Any change to OpenAI converter logic affects all of them.** Always test broadly: `make test-core` (all providers).
### 7. Scanner Buffer Pool Has a Capacity Cap
The SSE scanner buffer pool in `core/providers/utils/utils.go` starts at 4KB. Buffers grow dynamically but those exceeding **64KB are discarded** (not returned to pool) to prevent memory bloat. Be aware when working with providers that send very large SSE events.
### 8. Plugin Execution Order is Meaningful
Pre-hooks: registration order (first registered → first to run). Post-hooks: **reverse** order. This creates "wrapping" semantics — the first plugin registered is the outermost wrapper (its pre-hook runs first, post-hook runs last). Changing registration order changes behavior.
### 9. Fallbacks Re-execute the Full Plugin Pipeline
When a provider fails and the request falls to a fallback, the **entire plugin pipeline** re-executes from scratch. Governance checks, caching, and logging all run again for each attempt. Intentional, but surprising when debugging request counts or cost tracking.
### 10. `AllowedRequests` Nil Semantics
A **nil** `*AllowedRequests` means "all operations allowed." A **non-nil** value only allows fields explicitly set to `true`. This applies to both `ProviderConfig.AllowedRequests` and `CustomProviderConfig.AllowedRequests`.
### 11. BifrostContext Reserved Keys Are Silently Dropped
When `BlockRestrictedWrites()` is active, writes to reserved keys (governance IDs, retry counts, fallback index, etc.) are **silently ignored** — no error. If your plugin needs to pass data through context, use your own custom key type.
### 12. `fasthttp`, Not `net/http`
Bifrost uses `github.com/valyala/fasthttp` for provider HTTP calls. The API is different from `net/http`:
- Use `fasthttp.AcquireRequest()`/`fasthttp.ReleaseRequest()` for lifecycle
- `fasthttp.Client` pools connections per-host (`NetworkConfig.MaxConnsPerHost`, default 5000, 30s idle)
- Request/response bodies accessed via `resp.Body()` (returns `[]byte`, not `io.Reader`)
- **Exception:** Bedrock uses `net/http` (for AWS SigV4 signing) with `http.Transport` configured for HTTP/2 multi-connection support
### 13. `sonic`, Not `encoding/json`
JSON marshaling in hot paths uses `github.com/bytedance/sonic` for performance. `core/schemas/` uses standard `encoding/json` for custom marshaling (e.g., `NetworkConfig`). Don't mix them accidentally.
### 14. Atomic Pointer for Hot Config Reload
`Bifrost` uses `atomic.Pointer` for providers and plugins lists. On updates: create new slice → atomically swap pointer. **Never mutate the slice in place** — concurrent readers would see partial state.
### 15. MCP Tool Filtering is 4 Levels Deep
Tool access follows: Global filter → Client-level filter → Tool-level filter → Per-request filter (HTTP headers). All four levels must agree for a tool to be available. Changes to filtering logic must respect this hierarchy.
### 16. `config.schema.json` is the Source of Truth
`transports/config.schema.json` (~2700 lines) is the authoritative definition for all `config.json` fields. Documentation examples must match. When adding config fields: update schema first → handlers → docs.
### 17. UI `data-testid` Attributes Are Load-Bearing
E2E tests depend on `data-testid` attributes. Convention: `data-testid="<entity>-<element>-<qualifier>"`. If you rename or remove one, search `tests/e2e/` for references. If you add new interactive elements, add `data-testid`.
### 18. E2E Tests — Never Marshal Payloads to Maps
In `tests/e2e/core/`, **never marshal API payloads to a `Record`/`Map`/plain-object and then re-serialize**. Field ordering matters for backend validation and snapshot comparisons. Construct payloads as object literals with fields in the intended order and pass directly to Playwright's `request.post({ data })`. Avoid `Object.fromEntries()`, `JSON.parse(JSON.stringify(...))` round-trips, or destructuring into an intermediate `Record<string, unknown>` — these can silently reorder fields.
---
## Adding a New Provider — Full Checklist
1. Create `core/providers/<name>/` with files per the pattern (see "Provider Implementation" above)
2. Add `ModelProvider` constant in `core/schemas/bifrost.go`
3. Add to `StandardProviders` list in `core/schemas/bifrost.go`
4. Register in `core/bifrost.go` — add import + case in provider init switch
5. **UI integration** (all required):
- `ui/lib/constants/config.ts` — model placeholder + key requirement
- `ui/lib/constants/icons.tsx` — provider icon
- `ui/lib/constants/logs.ts` — provider display name (2 places)
- `docs/openapi/openapi.json` — OpenAPI spec update
- `transports/config.schema.json` — config schema (2 locations)
6. **CI/CD**: Add env vars to `.github/workflows/pr-tests.yml` and `release-pipeline.yml` (4 jobs)
7. **Docs**: Create `docs/providers/supported-providers/<name>.mdx`
8. **Test**: `make test-core PROVIDER=<name>`
---
## Testing
### Always prefer `make test-core` over raw `go test` for provider-level tests
The `make test-core` target is the canonical harness for provider tests — it wires up env vars from `.env` (provider API keys), invokes the per-provider `{provider}_test.go` entrypoint in `core/providers/<provider>/`, and routes through the shared `core/internal/llmtests/` scenario suite that validates end-to-end behavior (including streaming).
Running bare `go test ./core/providers/<provider>/...` only executes unit tests and skips the llmtests scenarios — so it won't catch regressions in streaming, tool-calling, or provider-specific response shapes.
```bash
make test-core PROVIDER=anthropic TESTCASE=TestChatCompletionStream # exact test
make test-core PROVIDER=openai PATTERN=Stream # substring match
make test-core PROVIDER=bedrock # all scenarios for one provider
make test-core DEBUG=1 PROVIDER=gemini TESTCASE=TestResponsesStream # attach Delve on :2345
```
`PATTERN` and `TESTCASE` are mutually exclusive. Provider name must match a directory under `core/providers/` (e.g. `anthropic`, `openai`, `bedrock`, `vertex`, `azure`, `gemini`, `cohere`, `mistral`, `groq`, etc.).
### LLM Tests (`core/internal/llmtests/`)
Scenario-based tests that run against **live provider APIs** with dual-API testing (Chat Completions + Responses API):
```go
func RunMyScenarioTest(t *testing.T, client *bifrost.Bifrost, ctx context.Context, cfg ComprehensiveTestConfig) {
// Use validation presets: BasicChatExpectations(), ToolCallExpectations(), etc.
// Use retry framework for flaky assertions
}
```
- Register in `tests.go` `testScenarios` slice
- Add `Scenarios.MyScenario` flag to `ComprehensiveTestConfig`
- Run: `make test-core PROVIDER=<name> TESTCASE=<TestName>`
### MCP Tests (`core/internal/mcptests/`)
Mock-based tests with `DynamicLLMMocker` and declarative setup:
```go
manager, mocker, ctx := SetupAgentTest(t, AgentTestConfig{
InProcessTools: []string{"echo", "calculator"},
AutoExecuteTools: []string{"*"},
MaxDepth: 5,
})
// Queue mock LLM responses, assert tool execution order
```
Categories: `agent_*_test.go`, `tool_*_test.go`, `connection_*_test.go`, `codemode_*_test.go`
Run: `make test-mcp TESTCASE=<TestName>`
### E2E Tests (`tests/e2e/`)
Playwright tests with page objects, data factories, fixtures:
- Page objects extend `BasePage`, use `getByTestId()` as primary selector strategy
- Data factories use `Date.now()` for unique names (prevents collision in parallel runs)
- Track created resources in arrays, clean up in `afterEach`
- Import `test`/`expect` from `../../core/fixtures/base.fixture` (never from `@playwright/test`)
- **Never marshal API payloads to a `Record`/`Map`/plain-object and then re-serialize.** Field ordering matters for snapshot comparisons and some backend validations. Construct payloads as object literals with fields in the intended order and pass directly to Playwright's `request.post({ data })`. Do NOT destructure into an intermediate `Record<string, unknown>` or use `Object.fromEntries()` / `JSON.parse(JSON.stringify(...))` round-trips, as these can reorder fields.
Run: `make run-e2e FLOW=<feature>`
---
## Claude Code Skills
Four skills are available via `/skill-name`:
### `/docs-writer <feature-name>`
Write, update, or review Mintlify MDX documentation. Researches UI code, Go handlers, and config schema. Validates `config.json` examples against `transports/config.schema.json`. Outputs docs with Web UI / API / config.json tabs.
Variants: `/docs-writer update <doc-path>`, `/docs-writer review <doc-path>`
### `/e2e-test <feature-name>`
Create, run, debug, audit, or auto-update Playwright E2E tests.
Variants:
- `/e2e-test fix <spec>` — Debug and fix a failing test
- `/e2e-test sync` — Detect UI changes, update affected tests automatically
- `/e2e-test audit` — Scan specs for incorrect/weak assertions (P0-P6 severity scale)
### `/investigate-issue <issue-id>`
Investigate a GitHub issue from `maximhq/bifrost`. Fetches issue details, classifies by type/area, searches codebase, traces dependencies, analyzes side effects, suggests tests (LLM/MCP/E2E), and presents an implementation plan with per-change approval gates.
### `/resolve-pr-comments <pr-number>`
Systematically address unresolved PR review comments. Uses GraphQL to get unresolved threads, presents each with FIX/REPLY/SKIP options, collects fixes locally, and only posts replies **after code is pushed** to remote.
---
## Common Workflows
### Modify chat completions across all providers
1. Change types in `core/schemas/chatcompletions.go`
2. Update converter functions in each provider's `chat.go`
3. If streaming affected, update `framework/streaming/` (accumulator, delta copy)
4. Run `make test-core` (all providers)
### Add a new field to API responses
1. Add to schema type in `core/schemas/`
2. Map in provider response converter (`ToBifrost*Response`)
3. Handle in streaming accumulator if applicable
4. Update HTTP handler if field needs special serialization
5. Update `transports/config.schema.json` if configurable
### Add a new plugin
1. Create `plugins/<name>/` with its own `go.mod`
2. Implement `LLMPlugin`, `MCPPlugin`, or `HTTPTransportPlugin` interface
3. Add to `go.work`
4. Register in transport layer or Bifrost config
5. Add test targets to `Makefile`
### Modify a UI feature
1. Find workspace page: `ui/app/workspace/<feature>/`
2. Check existing `data-testid` attributes — E2E tests depend on them
3. Add `data-testid` to new interactive elements
4. Run `make run-e2e FLOW=<feature>` to verify
5. If E2E tests break, use `/e2e-test sync` to update them
---
## Key Files Quick Reference
| What | Where |
|------|-------|
| Main Bifrost struct & queuing | `core/bifrost.go` |
| Inference routing & fallbacks | `core/inference.go` |
| Provider interface (30+ methods) | `core/schemas/provider.go` |
| ModelProvider enum & context keys | `core/schemas/bifrost.go` |
| Plugin interfaces & pooled HTTP types | `core/schemas/plugin.go` |
| BifrostContext (mutable context) | `core/schemas/context.go` |
| Chat completion types | `core/schemas/chatcompletions.go` |
| Responses API types | `core/schemas/responses.go` |
| Object pool (prod + debug) | `core/pool/pool_prod.go`, `pool_debug.go` |
| Shared provider utils & SSE parsing | `core/providers/utils/utils.go` |
| Streaming accumulator | `framework/streaming/accumulator.go` |
| HTTP inference handler | `transports/bifrost-http/handlers/inference.go` |
| Governance handler | `transports/bifrost-http/handlers/governance.go` |
| Config schema (source of truth) | `transports/config.schema.json` |
| Pool debug profiler | `transports/bifrost-http/handlers/devpprof.go` |
| LLM test infrastructure | `core/internal/llmtests/` |
| MCP test infrastructure | `core/internal/mcptests/` |
| E2E test infrastructure | `tests/e2e/core/` |
| Docs navigation config | `docs/docs.json` |
| CI/CD workflows | `.github/workflows/` |
---
## Code Style
- **Go**: `gofmt`/`goimports`. No custom linter config.
- **TypeScript/React**: Oxfmt. TanStack Router.
- **JSON tags**: `snake_case` matching provider API conventions.
- **Error strings**: Lowercase, no trailing punctuation (Go convention).
- **Provider types**: Prefixed with provider name in PascalCase (`AnthropicChatRequest`, `GeminiEmbeddingResponse`).
- **Converter functions**: Pure — no side effects, no logging, no HTTP.
- **Pool names**: Descriptive string passed to `pool.New()` (e.g., `"channel-message"`, `"response-stream"`).
- **Context keys**: Use `BifrostContextKey` type. Custom plugins should define their own key types to avoid collisions.
- **Go filenames**: No underscores. The only permitted underscore is the `_test.go` suffix. Examples: `pluginpipeline.go`, `pluginpipeline_test.go` — never `plugin_pipeline.go` or `plugin_pipeline_race_test.go`. Concatenate words (lowercase, no separators) for multi-word filenames.
# Frontend Code Guidelines & Patterns
This document defines the standards, structure, and best practices for writing frontend code in this project.
---
## Tech Stack
- **React** (with Vite)
- **TypeScript**
- **@tanstack/react-router** (type-safe routing)
- **Tailwind CSS v4**
- **Radix UI** (primitives)
- **Local UI component library** (`ui/components/ui/`) built on Radix primitives
---
## Folder Structure
```text
/ui
├── app # Routes & pages
├── components # Shared components
│ └── ui # Core design system components
├── hooks # Custom React hooks
├── lib # Utilities, helpers, shared logic
└── app/enterprise # Enterprise-specific code (via symlink)
```
### Rules
- All frontend code must live inside `/ui`
- Routes and pages → `ui/app`
- Shared/reusable components → `ui/components`
- Core UI primitives → `ui/components/ui`
- Utilities and libraries → `ui/lib`
- Custom hooks → `ui/hooks`
---
## Libraries & Usage
### Core Libraries
- `react` → UI library
- `typescript` → Type safety
- `tailwindcss` → Styling
- `@tanstack/react-router` → Routing
### UI & Visualization
- `@radix-ui/react-*` → UI primitives
- `ui/components/ui/*` → Project's Radix-based component system
- `recharts` → Charts
- `monaco-editor` → Code editor
### Utilities
- `date-fns` → Date/time formatting
- `nuqs` → Query param state management
### Tooling
- `Oxfmt` → Code formatting
- `vitest` → Testing
---
## Routing Convention
For every new route:
```text
ui/app/<route-name>/
├── layout.tsx # Route definition using createFileRoute
├── page.tsx # Page content
└── views/ # Optional: route-specific components
```
### Rules
- Folder name must match route name
- Always use `createFileRoute` in `layout.tsx`
- `page.tsx` should only handle composition (not heavy logic)
- Route-specific components go inside `views/`
---
## Component Guidelines
### Reusability First
- Always check if similar components/functions already exist
- Prefer extending or refactoring existing code over duplication
- Only create new components if reuse is not feasible
---
### Component Placement
- Shared → `ui/components`
- Route-specific → `views/` inside route folder
---
### JSX & Rendering
- Avoid deeply nested conditional rendering
- Break complex UI into smaller components
- Keep components readable and maintainable
---
### Lists & Keys
- Always use **stable, unique keys**
- Never use array index as key (unless unavoidable)
---
## React Best Practices
- Avoid unnecessary or unstable dependencies in hooks
- Prevent infinite loops in `useEffect`
- Keep dependency arrays accurate and minimal
- Prefer derived state over duplicated state
---
## State Management
### Priority Order
1. Query Params (`nuqs`) → for persistent/shareable state
2. Local State → for UI-only state
3. Redux → only when truly necessary
---
### Query Params (`nuqs`)
- Use for state that should persist across refresh/navigation
- Use proper parsers like `parseAsString` or `parseAsInteger`
- Do NOT mix query param state with local/redux state
- Follow a single consistent pattern across the codebase
---
### Redux
- Use only when global/shared state is required
- Avoid unnecessary slices
- Prefer simpler alternatives when possible
---
### RTK Query (`@reduxjs/toolkit/query`)
- Use for API calls and caching
- Use **granular tags** for cache invalidation
- Avoid invalidating entire datasets unnecessarily
- Implement **optimistic updates** where applicable
---
## Forms
We use:
- `react-hook-form`
- `zod v4` (for schema validation)
### Rules
- Always define a Zod schema
- Include meaningful validation messages
- Prefer **inline field errors** (not toast notifications)
- Use `refine` / `superRefine` for complex validation
- Store schemas in: `ui/lib/types/schemas.ts`
---
## Tables
- Use `@tanstack/react-table` **only for large/complex datasets**
- For simple tables → build custom lightweight components
- Prioritize performance over abstraction
---
## ⚡ Performance Guidelines
- Lazy load heavy or rarely-used libraries
- Avoid unnecessary re-renders
- Split large components into smaller ones
- Keep bundle size minimal
---
## Dependency Rules
- Do NOT add new dependencies unless absolutely necessary
- Always pin exact versions (no `^` or `~`)
- Prefer existing libraries in the codebase
---
## TypeScript Guidelines
- Avoid using `any` unless absolutely unavoidable
- Prefer strict typing and inference
- Define reusable types in shared locations
---
## Code Quality & Formatting
After writing code:
```bash
cd ui && npm run format
````
Then verify build:
```bash
cd ui && npm run build
```
* Code must pass formatting and build checks
* Follow consistent naming and structure conventions
---
## Anti-Patterns to Avoid
* Duplicate components without considering reuse
* Mixing multiple state management approaches unnecessarily
* Overusing Redux
* Using unstable hook dependencies
* Adding heavy libraries for simple use cases
* Poorly structured or deeply nested JSX
---
## Summary
* Prioritize **reusability, performance, and consistency**
* Follow **strict folder structure and routing conventions**
* Use **the right tool for the right problem**
* Keep code **simple, predictable, and maintainable**

128
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
akshay@getmaxim.ai.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

Some files were not shown because too many files have changed in this diff Show More